sloplint 0.6.0 → 0.8.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -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
@@ -53,12 +55,41 @@ module Sloplint
53
55
  # letter is a letter.
54
56
  WHOLE_CLOSERS = /point|game|thing|deal|story|ballgame|ball#{WRAP_GAP}+game|(?:value|fix)(?=[^[:word:][:space:]'’-]|#{WRAP_GAP}+(?:[^[:word:][:space:]*_`"'‘“(\[{~]|\d+[.)][ \t])|#{WRAP_GAP}*(?:\z|#{PARA_BREAK}|(?:of|to|for|in|on|at|with|here|there|behind|right|though|now|anyway|really|from|over|after|and|but|so|as|that|which|if|when|because|since|unless|until|once|while|where|i|we|you|he|she|it|they|the|a|an|this|these|those|every|any|my|our|your|his|her|their|its)\b))/i
55
57
 
58
+ # Nouns that only ever name the writer's own construction -- never a
59
+ # concrete object, a person, or an idiom -- and that all four of
60
+ # clean-x, cleanest-x, honest-x and most-honest-x already matched before
61
+ # this constant existed. "comparison" and "through-line" read the same way
62
+ # but are each missing from one rule's original list (clean-x never had
63
+ # "comparison", cleanest-x never had "through-line"), so both stay out of
64
+ # this constant and inline in the three rules that carry them -- putting
65
+ # either one here would add a match the fourth rule never had.
66
+ WRITERS_OWN_CONSTRUCTION_NOUNS = "framing|formulation|mapping|abstraction"
67
+
68
+ # A sentence of at least sixty characters, ending in sentence-ending
69
+ # punctuation followed by one or two spaces -- the setup mic-drop-closer's
70
+ # kicker needs before it looks for the closer. The atomic group (?>...) is
71
+ # load-bearing against catastrophic backtracking; see the comment on
72
+ # mic-drop-closer's pattern for the incident that made it necessary.
73
+ SENTENCE_OF_SIXTY_CHARACTERS_ENDING_IN_PUNCTUATION_AND_SPACE =
74
+ /(?:^|(?<=[.!?])[ \t]{1,2})(?>(?:[^.!?\n\s]|(?<![ \t])[ \t]{1,2}(?![ \t])|\r?\n(?!\s*\n)[ \t]*){60,})[.!?][ \t]{1,2}/
75
+
76
+ # real-x-real-y interpolates this fragment after each of its two "real"s, so
77
+ # a later narrowing can only ever apply to both at once -- two copies typed
78
+ # out separately could drift, one gaining an exception the other never
79
+ # sees. Function words that only ever continue a predicative "is real",
80
+ # then the fixed senses common in this register that are never the
81
+ # doubled-intensifier tell: real time, real-world, the math sense (real
82
+ # numbers/roots), real user (monitoring), real estate, real money.
83
+ REAL_X_REAL_Y_EXCLUDED_NEXT_WORD =
84
+ "and|but|or|nor|yet|too|enough|itself|indeed|which|that|here|there|so" \
85
+ "|time|world|numbers?|roots?|money|estate|user"
86
+
56
87
  RULES = [
57
- # ── rhetorical-tic ────────────────────────────────────────────────────
58
88
  Rule.new(
59
89
  id: "no-x-no-y",
60
- category: "rhetorical-tic",
90
+ category: "cadence",
61
91
  severity: "warning",
92
+ confidence: "high",
62
93
  # Comma chains only: "no fluff, no filler, no jargon". The comma is the
63
94
  # evidence -- it makes the parallelism deliberate. Fragment chains split
64
95
  # by sentence punctuation are a separate, quieter rule (no-x-no-y-frag),
@@ -75,10 +106,11 @@ module Sloplint
75
106
  ),
76
107
  Rule.new(
77
108
  id: "no-x-no-y-frag",
78
- category: "rhetorical-tic",
79
- severity: "info",
109
+ category: "cadence",
110
+ severity: "warning",
111
+ confidence: "medium",
80
112
  # The same cadence built from sentence fragments: "No fluff. No filler."
81
- # Ships at info, not warning, because the shape is genuinely ambiguous --
113
+ # Medium confidence, because the shape is genuinely ambiguous --
82
114
  # two short "no" sentences in a row is also just writing ("No one moved.
83
115
  # No one spoke."). The agent reading the flag decides; see rationale.
84
116
  #
@@ -118,8 +150,9 @@ module Sloplint
118
150
  ),
119
151
  Rule.new(
120
152
  id: "thats-the-whole",
121
- category: "rhetorical-tic",
153
+ category: "closer",
122
154
  severity: "warning",
155
+ confidence: "high",
123
156
  # The nouns are WHOLE_CLOSERS. "value" and "fix" are the closers agents
124
157
  # write in technical prose ("That's the whole fix"), where the
125
158
  # contraction keeps them out of is-the-whole-x, which sees only "is".
@@ -169,8 +202,9 @@ module Sloplint
169
202
  ),
170
203
  Rule.new(
171
204
  id: "is-the-whole-x",
172
- category: "rhetorical-tic",
173
- severity: "info",
205
+ category: "closer",
206
+ severity: "warning",
207
+ confidence: "medium",
174
208
  # The generic form of thats-the-whole: a subject, then "is the whole /
175
209
  # real / actual / entire N" with N from a closed abstract list. "That
176
210
  # periodicity is the whole tell.", "Consistency is the real test." The
@@ -187,7 +221,7 @@ module Sloplint
187
221
  # "cost" because "the real deal", "the real thing" and "the whole cost"
188
222
  # are idioms or quantities. The noun may not run on into a compound
189
223
  # ("problem-solver"). Gaps may cross a hard-wrapped newline but never
190
- # a paragraph break. Ships at info because "the real question" and
224
+ # a paragraph break. Medium confidence: "the real question" and
191
225
  # "the whole point" are also how people talk.
192
226
  pattern: /(?<![\w'’-])
193
227
  (?!(?:that|this)#{WRAP_GAP}+(?:is)#{WRAP_GAP}+(?:the)#{WRAP_GAP}+(?:whole)#{WRAP_GAP}+(?:#{WHOLE_CLOSERS})(?![\w'’-]))
@@ -236,8 +270,9 @@ module Sloplint
236
270
  ),
237
271
  Rule.new(
238
272
  id: "bare-equative",
239
- category: "rhetorical-tic",
240
- severity: "info",
273
+ category: "closer",
274
+ severity: "warning",
275
+ confidence: "medium",
241
276
  # A sentence that opens on an abstract head noun and equates it with
242
277
  # something: "The tell here is the periodicity.", "The problem is not
243
278
  # the tool.", "The lesson is the handoff." The head noun list is closed
@@ -251,8 +286,8 @@ module Sloplint
251
286
  # answer is a mess"), a pointing complement ("the same", "the one",
252
287
  # "the first"), and a proper noun ("the Slack thread") are all out.
253
288
  # Gaps may cross a hard-wrapped newline but never a paragraph break.
254
- # Ships at info: "The problem is the cost" is how people write too, at
255
- # 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
256
291
  # tells.
257
292
  pattern: /(?:^|(?<=[.!?])[ \t]{1,2})(?:[-*+•][ \t]+|\d+[.)][ \t]+)?\KThe(?:[ \t]|\r?\n(?!\s*\n))+
258
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))+
@@ -293,9 +328,9 @@ module Sloplint
293
328
  ),
294
329
  Rule.new(
295
330
  id: "epistrophe",
296
- category: "rhetorical-tic",
331
+ category: "cadence",
297
332
  severity: "info",
298
- default_on: false,
333
+ confidence: "low",
299
334
  # Two clauses that end on the same two-word phrase, the second closing
300
335
  # the sentence: "built for one desk, and almost no job is done at one
301
336
  # desk." Two backreferences catch the repeat, one per word, so the
@@ -343,10 +378,95 @@ module Sloplint
343
378
  "which is why the rule is off by default; when selected, several in a " \
344
379
  "draft should be read as a warning."
345
380
  ),
381
+ Rule.new(
382
+ id: "phrase-echo",
383
+ category: "cadence",
384
+ severity: "info",
385
+ confidence: "low",
386
+ # The same three words again a few paragraphs on. Three consecutive
387
+ # words, each four characters or more and lowercase-led, one of them
388
+ # six letters with nothing but letters, and the same three again
389
+ # within about 400 words. That is the whole defence, and it is
390
+ # structural: length drops the function-word runs, the all-letters
391
+ # word drops the contractions and hyphenated compounds that would
392
+ # otherwise pass on characters alone, case drops the names and
393
+ # headings. The gaps inside the phrase are capped at two spaces, as
394
+ # in epistrophe, so a code span blanked by --markdown cannot weld two
395
+ # words into a phrase.
396
+ #
397
+ # The window is a lazy walk of word steps in an atomic group, so
398
+ # nothing backtracks, and it counts Unicode words, since `\w` is
399
+ # ASCII in Ruby. Each gap in the walk is capped at 80 non-word
400
+ # characters and refuses to cross into a list item or a table row:
401
+ # a blanked code fence, a rule of dashes or a bullet is a wall, not a
402
+ # step. The repeat sits in a lookahead so the match, and the excerpt,
403
+ # is the first occurrence alone rather than the whole span; the
404
+ # suggestion says so. The repeat may not open on a quote mark, a
405
+ # backtick, an emphasis marker, a hyphen or a table bar: a quoted
406
+ # self-repeat is deliberate, and "re-shared" is not "shared". One
407
+ # backreference per word so either occurrence may be hard-wrapped.
408
+ #
409
+ # Off by default: in reference prose the rate runs to thousands per
410
+ # million words, all of it terms of art and running epithets.
411
+ pattern: /(?<![\p{Word}'’-])
412
+ (?=(?:[\w'’-]+(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})){0,2}[a-z]{6,}(?![\w'’-]))
413
+ ([a-z][\w'’-]{3,})(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
414
+ ([a-z][\w'’-]{3,})(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
415
+ ([a-z][\w'’-]{3,})\b
416
+ (?=(?>(?:(?!\r?\n[ \t]*(?:[-*+•|]|\d+[.)])[ \t])\P{Word}){1,80}\p{Word}+){0,400}?
417
+ (?:(?!\r?\n[ \t]*(?:[-*+•|]|\d+[.)])[ \t])[^\p{Word}"“‘'`*|-]){1,80}
418
+ \1(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
419
+ \2(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
420
+ \3\b)/x,
421
+ message: "Three-word phrase that comes back within a few hundred words -- a model reusing its own output.",
422
+ suggestion: "This is the first use and the repeat is ahead. Reword the repeat, unless the phrase is a term the reader needs to see again.",
423
+ examples_bad: [
424
+ "The shortest honest answer that came out of the review was a list.\n\nWhen you write back, the shortest honest answer you can send is the list.",
425
+ "We keep a shared review checklist in the repo, and it is short. Everyone who opens a pull request edits the shared review checklist first.",
426
+ # A hard-wrapped first occurrence.
427
+ "We keep a shared review\nchecklist in the repo. Everyone edits the shared review checklist first.",
428
+ # The last word inside the window.
429
+ "We keep a shared review checklist. #{"word " * 399}The shared review checklist is short."
430
+ ],
431
+ examples_ok: [
432
+ # Function words fall out on length.
433
+ "In order to ship we cut scope, and in order to ship again we cut it more.",
434
+ # Four-letter words alone are not enough; one word must be six letters, letters only.
435
+ "It would have been better, and it would have been faster.",
436
+ "It couldn't have been worse, and it couldn't have been better.",
437
+ "We saw every top-ten list here, and every top-ten list there.",
438
+ # Proper nouns and Title Case headings fall out on case.
439
+ "Grand Central Station has one, and Grand Central Station wants two.",
440
+ "Incident Response Plan\n\nThe Incident Response Plan covers the first hour.",
441
+ # The repeat is beyond the window, in words, in non-ASCII words, or past a wall of dashes.
442
+ "We keep a shared review checklist. #{"word " * 400}The shared review checklist is short.",
443
+ "We keep a shared review checklist. #{"слово " * 400}The shared review checklist is short.",
444
+ "We keep a shared review checklist.\n\n#{"-" * 100}\n\nThe shared review checklist is short.",
445
+ # A quoted repeat is deliberate, whatever the quote mark.
446
+ "The shared review checklist is new. He wrote \"shared review checklist\" on the board.",
447
+ "The shared review checklist is new. He wrote 'shared review checklist' on the board.",
448
+ "The shared review checklist is new. He wrote `shared review checklist` on the board.",
449
+ # A different word form is a different phrase, and so is a hyphenated compound.
450
+ "The shared review checklist grew, and then both shared review checklists grew.",
451
+ "The auto-generated review checklist was long. The hand-generated review checklist was longer.",
452
+ "We use a shared review checklist daily. Nobody re-shared review checklist edits.",
453
+ # A run of spaces where --markdown blanked a code span does not weld a phrase.
454
+ "We keep a shared review checklist here. Everyone edits the shared review checklist first.",
455
+ # List items and table rows are furniture, not prose.
456
+ "- shared review checklist covers pull requests\n- shared review checklist covers deploys",
457
+ "1. Update the shared config file.\n2. Restart the shared config file watcher.",
458
+ "| Task | Status |\n|---|---|\n| A | needs manual review |\n| B | needs manual review |"
459
+ ],
460
+ rationale: "A model reuses a phrase it has just minted because its own recent output is " \
461
+ "the likeliest continuation, so the same three words turn up again a few " \
462
+ "paragraphs on, doing no new work. Terms of art repeat too, and the pattern " \
463
+ "cannot tell a coined phrase from a name, so the rule is off by default."
464
+ ),
346
465
  Rule.new(
347
466
  id: "did-not-x-did-not-y",
348
- category: "rhetorical-tic",
467
+ category: "cadence",
349
468
  severity: "warning",
469
+ confidence: "high",
350
470
  pattern: /\b(?:did\s+not|didn't)\s+[\w'-]+(?:,?\s+(?:and\s+)?(?:did\s+not|didn't)\s+[\w'-]+)+/i,
351
471
  message: '"did not X, did not Y" chain (%{count} items) reads as AI cadence.',
352
472
  suggestion: "Cut the chain or make it one plain sentence.",
@@ -357,8 +477,9 @@ module Sloplint
357
477
  ),
358
478
  Rule.new(
359
479
  id: "from-x-to-y-chain",
360
- category: "rhetorical-tic",
480
+ category: "cadence",
361
481
  severity: "warning",
482
+ confidence: "high",
362
483
  # Two or more "from X to Y" spans in a row, comma-separated: "from
363
484
  # private notes to shared files, from personal memory to team context".
364
485
  # Each span is "from", one to three words, "to", one to three words; the
@@ -433,8 +554,9 @@ module Sloplint
433
554
  ),
434
555
  Rule.new(
435
556
  id: "one-x-one-y",
436
- category: "rhetorical-tic",
557
+ category: "cadence",
437
558
  severity: "warning",
559
+ confidence: "high",
438
560
  # Three or more "one X" items in a comma chain that stands on its own:
439
561
  # "One owner, one repository, one weekly prune." or, after a colon,
440
562
  # "the setup is narrow: one reviewer, one queue, one deadline". The
@@ -504,8 +626,9 @@ module Sloplint
504
626
  ),
505
627
  Rule.new(
506
628
  id: "and-what-it-should",
507
- category: "rhetorical-tic",
629
+ category: "closer",
508
630
  severity: "warning",
631
+ confidence: "high",
509
632
  # The elliptical tail: "List what the assistant knows about the client,
510
633
  # and what it should." The second "what" clause borrows its verb from the
511
634
  # first and ends on a bare modal or a negated auxiliary, so the sentence
@@ -551,8 +674,9 @@ module Sloplint
551
674
  ),
552
675
  Rule.new(
553
676
  id: "abstract-lives-in",
554
- category: "rhetorical-tic",
555
- severity: "info",
677
+ category: "puffery",
678
+ severity: "warning",
679
+ confidence: "medium",
556
680
  # An abstraction given an address: "the craft that lives between the two
557
681
  # desks", "its context lives in a folder nobody else can open", "the
558
682
  # value sits in the follow-up". The subject list is closed and abstract,
@@ -568,7 +692,7 @@ module Sloplint
568
692
  # lies in the assumption") and "resides in" are ordinary English for
569
693
  # where a fault or an authority is, at any register.
570
694
  #
571
- # Ships at info. The same shape states where information literally is
695
+ # Medium confidence. The same shape states where information literally is
572
696
  # ("the knowledge lives in our heads", "the instructions live in the
573
697
  # README"), and a sample of pre-2022 Hacker News biased toward the
574
698
  # construction turns up a few of those per million words, all human.
@@ -611,8 +735,9 @@ module Sloplint
611
735
  ),
612
736
  Rule.new(
613
737
  id: "the-x-is-the-x",
614
- category: "rhetorical-tic",
615
- severity: "warning",
738
+ category: "cadence",
739
+ severity: "error",
740
+ confidence: "high",
616
741
  # The repeated-head equative: "the reason it holds up is the reason the
617
742
  # other half happens", "the problem with A is the problem with B". The
618
743
  # same abstract head noun sits on both sides of the copula, caught by a
@@ -674,8 +799,9 @@ module Sloplint
674
799
  ),
675
800
  Rule.new(
676
801
  id: "same-determiner-chain",
677
- category: "rhetorical-tic",
802
+ category: "cadence",
678
803
  severity: "info",
804
+ confidence: "medium",
679
805
  # The quiet cousin of one-x-one-y and no-x-no-y: three or more items in
680
806
  # a comma chain that all open on the same determiner or quantifier,
681
807
  # caught with a backreference: "every faculty, every thought, every
@@ -736,8 +862,9 @@ module Sloplint
736
862
  ),
737
863
  Rule.new(
738
864
  id: "dont-verb-it",
739
- category: "rhetorical-tic",
865
+ category: "false-correction",
740
866
  severity: "warning",
867
+ confidence: "high",
741
868
  pattern: /\b(?:don't|do\s+not)\s+(\w+)\s+it\b[^.!?]*[.!?]\s*\1\s+it\b/i,
742
869
  message: '"Don\'t X it. X it Y." reframing is a stock LLM move.',
743
870
  suggestion: "Drop the fake reframe and state the claim once.",
@@ -747,8 +874,9 @@ module Sloplint
747
874
  ),
748
875
  Rule.new(
749
876
  id: "sit-with-that",
750
- category: "rhetorical-tic",
877
+ category: "reader-address",
751
878
  severity: "warning",
879
+ confidence: "high",
752
880
  # Two branches. The deictic object is the tic anywhere in a sentence, so
753
881
  # "sit with that/this/it" needs no anchor. Anything else needs the
754
882
  # sentence-initial imperative, which is where the tic lives and where the
@@ -775,12 +903,13 @@ module Sloplint
775
903
  "The object does not matter: the tic is telling the reader to dwell instead " \
776
904
  "of giving them something to dwell on. The sentence-initial imperative does " \
777
905
  "flag the plain companion sense (\"Sit with the baby while I run out\"), " \
778
- "which is accepted -- hence warning, not error."
906
+ "which is accepted."
779
907
  ),
780
908
  Rule.new(
781
909
  id: "hold-onto-that",
782
- category: "rhetorical-tic",
910
+ category: "reader-address",
783
911
  severity: "warning",
912
+ confidence: "high",
784
913
  # Sentence-initial imperative only. Past tense and subordinate clauses
785
914
  # ("she held on to that letter", "if you hold onto that phrase") are a
786
915
  # different construction, not the tell. Everything AFTER that/this is
@@ -806,8 +935,9 @@ module Sloplint
806
935
  ),
807
936
  Rule.new(
808
937
  id: "cleanly",
809
- category: "rhetorical-tic",
810
- severity: "info",
938
+ category: "self-rating",
939
+ severity: "warning",
940
+ confidence: "medium",
811
941
  # The bare adverb was the whole rule, and the engineering idioms were
812
942
  # flagged on purpose. Technical prose says that was the wrong call: a
813
943
  # patch that applies cleanly, a build that compiles cleanly and a gear
@@ -823,7 +953,7 @@ module Sloplint
823
953
  # parts. The clause-final form ("the objection breaks down cleanly, and
824
954
  # neither half survives") is the same tell and is left out.
825
955
  #
826
- # 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.
827
957
  # "The argument splits cleanly into two parts" and "the gear retracted
828
958
  # cleanly into the well" are one pattern apart only in their subject,
829
959
  # and a regex cannot see the subject -- the same limit that keeps the
@@ -858,8 +988,9 @@ module Sloplint
858
988
  ),
859
989
  Rule.new(
860
990
  id: "clean-count",
861
- category: "rhetorical-tic",
991
+ category: "self-rating",
862
992
  severity: "warning",
993
+ confidence: "high",
863
994
  # Needs a partition noun. The bare count reaches the laundry: Ulysses
864
995
  # has "four clean strokes", Jane Eyre "two clean tuckers".
865
996
  pattern: /\b(?:two|three|four|five|six|seven|2|3|4|5|6|7)\s+
@@ -885,16 +1016,17 @@ module Sloplint
885
1016
  ),
886
1017
  Rule.new(
887
1018
  id: "cleanest-x",
888
- category: "rhetorical-tic",
889
- severity: "warning",
1019
+ category: "self-rating",
1020
+ severity: "error",
1021
+ confidence: "high",
890
1022
  # Noun list only. "The cleanest way to install the driver" and "the
891
1023
  # cleanest cut of meat" are ordinary English, so "way" is admitted only
892
1024
  # in front of a speech verb, and the concrete-capable nouns (cut, line,
893
1025
  # version, split) are left out entirely.
894
1026
  pattern: /\bcleanest\s+(?:\w+\s+){0,2}
895
- (?:framing|formulation|statement|account|argument|idea|definition|summary
896
- |reading|take|point|story|explanation|distinction|comparison|mapping
897
- |abstraction)\b
1027
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|comparison
1028
+ |statement|account|argument|idea|definition|summary
1029
+ |reading|take|point|story|explanation|distinction)\b
898
1030
  |\bcleanest\s+way\s+to\s+(?:say|put|frame|state|describe|phrase|express
899
1031
  |think\s+about)\b/ix,
900
1032
  message: '"The cleanest framing/way to put it…" ranks your own claim for the reader.',
@@ -914,15 +1046,16 @@ module Sloplint
914
1046
  ),
915
1047
  Rule.new(
916
1048
  id: "clean-x",
917
- category: "rhetorical-tic",
918
- severity: "info",
919
- # The quiet half of cleanest-x, at info because the positive degree is
920
- # 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
921
1054
  # standard engineering English, so "separation" stays out of the list;
922
1055
  # "break" is admitted only in "clean break between", never bare, because
923
1056
  # "make a clean break with the past" is an idiom and not a tell.
924
1057
  pattern: /\b(?:a|the|one)\s+(?:\w+\s+)?clean\s+(?:\w+\s+)?
925
- (?:abstraction|distinction|framing|formulation|mapping|through-line
1058
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|distinction|through-line
926
1059
  |story|answer|argument|split|divide)\b
927
1060
  |\bclean\s+(?:line|break|split)\s+between\b/ix,
928
1061
  message: '"A clean abstraction / clean framing" praises the idea instead of showing it.',
@@ -939,13 +1072,14 @@ module Sloplint
939
1072
  "The report gives a clean bill of health."
940
1073
  ],
941
1074
  rationale: "'Clean' in front of an idea is evaluation, not description -- it says the " \
942
- "writer approves, and nothing about the idea. It ships at info because the " \
943
- "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."
944
1077
  ),
945
1078
  Rule.new(
946
1079
  id: "you-already-know",
947
- category: "rhetorical-tic",
1080
+ category: "reader-address",
948
1081
  severity: "warning",
1082
+ confidence: "high",
949
1083
  pattern: /\byou\s+already\s+know\b/i,
950
1084
  message: '"You already know…" is a stock LLM rhetorical setup.',
951
1085
  suggestion: "Just make the point; don't tell the reader they know it.",
@@ -955,8 +1089,9 @@ module Sloplint
955
1089
  ),
956
1090
  Rule.new(
957
1091
  id: "is-the-entire",
958
- category: "rhetorical-tic",
1092
+ category: "closer",
959
1093
  severity: "warning",
1094
+ confidence: "high",
960
1095
  pattern: /\bis\s+the\s+entire\s+(?:point|game|thing|business\s+model|deal|story)\b/i,
961
1096
  message: '"X is the entire point/game/…" is an LLM emphasis tic.',
962
1097
  suggestion: "State the point plainly without the superlative frame.",
@@ -966,8 +1101,9 @@ module Sloplint
966
1101
  ),
967
1102
  Rule.new(
968
1103
  id: "the-entire-is",
969
- category: "rhetorical-tic",
1104
+ category: "closer",
970
1105
  severity: "warning",
1106
+ confidence: "high",
971
1107
  pattern: /\bthe\s+entire\s+(?:point|game|thing|business\s+model|deal|story)\s+is\b/i,
972
1108
  message: '"The entire point/game/… is" is an LLM emphasis tic.',
973
1109
  suggestion: "State the point plainly without the superlative frame.",
@@ -977,8 +1113,9 @@ module Sloplint
977
1113
  ),
978
1114
  Rule.new(
979
1115
  id: "is-real-and-not",
980
- category: "rhetorical-tic",
981
- severity: "info",
1116
+ category: "false-concession",
1117
+ severity: "warning",
1118
+ confidence: "medium",
982
1119
  pattern: /\bis\s+real,?\s+(?:and|but|not)\b/i,
983
1120
  message: '"The X is real, and…" is a stock LLM concession move.',
984
1121
  suggestion: "Drop the 'is real, and' scaffolding; assert the point directly.",
@@ -997,10 +1134,110 @@ module Sloplint
997
1134
  "are three words apart and identical on the surface. An agent reading the flag " \
998
1135
  "has the rest of the sentence to judge; the pattern alone doesn't."
999
1136
  ),
1137
+ Rule.new(
1138
+ id: "real-x-real-y",
1139
+ category: "cadence",
1140
+ severity: "info",
1141
+ confidence: "medium",
1142
+ # Ships at info, not warning: the only two hits the probe found in
1143
+ # 1.28M words were both false positives, and one corpus of one
1144
+ # register is thin evidence next to honestly, which sits at warning
1145
+ # on 9.7M words across two. A doubled "real" is also defensible on
1146
+ # its own ("No real financial loss, but a real failure") in a way
1147
+ # the warning rules around it are not. The agent reading the flag
1148
+ # decides.
1149
+ #
1150
+ # "If it can trigger real API calls or hold real credentials, it needs
1151
+ # the same rigor." -- the same intensifier, said twice in one sentence,
1152
+ # each time in front of a different noun. Bare "real" is an ordinary
1153
+ # word (a real number, real time, a real problem) and cannot be flagged
1154
+ # on its own; the narrowing here is repetition, not a noun list --
1155
+ # requiring two attributive uses in one sentence, naming two different
1156
+ # things, is what tells the doubled intensifier apart from plain
1157
+ # English, and it needs no list of nouns to do it.
1158
+ #
1159
+ # "Attributive" is enforced by requiring "real" to run straight into
1160
+ # the word it modifies: a space, then a letter, with no comma or
1161
+ # conjunction between. That already keeps the predicative use out
1162
+ # ("The risk is real, and it is growing" -- its own rule, above) since
1163
+ # a predicate "real" is followed by punctuation or "and", never
1164
+ # directly by the next noun.
1165
+ #
1166
+ # A hyphen touching "real" on either side takes it out of the running:
1167
+ # "real-time" and "real-world" are compounds, one modifier, not two
1168
+ # independent uses of the intensifier, and "non-real" is a negation,
1169
+ # not the intensifier at all. (?<!-) and (?!-) drop all three.
1170
+ #
1171
+ # The second "real" must name a word the first one didn't -- a
1172
+ # backreference, so "a real risk ... that real risk" (the same thing,
1173
+ # referred to twice) is ordinary reference, not the tic. Two mentions
1174
+ # only look alike when they're both new: two different real things.
1175
+ #
1176
+ # The closed list after each "real" (REAL_X_REAL_Y_EXCLUDED_NEXT_WORD,
1177
+ # above) is the handful of fixed senses that are common in this
1178
+ # register and are never the tell, plus the function words that only
1179
+ # ever continue a predicative "is real". This gives up "real user"
1180
+ # outside "real user monitoring" and any doubled "real name"/"real
1181
+ # names" pairing that differs only by a plural -- known, deliberate
1182
+ # recall losses rather than a growing list.
1183
+ #
1184
+ # The gap between the two "real"s crosses a hard-wrapped line the same
1185
+ # way WRAP_GAP does (a newline is fine unless it opens a paragraph
1186
+ # break, PARA_BREAK, since a non-breaking space alone on the line in
1187
+ # between still reads as a blank line to the editors that emit one),
1188
+ # but never a sentence-ending mark, so the two "real"s must fall in
1189
+ # one sentence.
1190
+ pattern: /
1191
+ (?<!-)\breal(?!-)[ \t]+
1192
+ (?!(?:#{REAL_X_REAL_Y_EXCLUDED_NEXT_WORD})\b)
1193
+ (?>([a-z][\w'’-]*))\b
1194
+ (?:(?!\breal\b)[^.!?\n]|(?!#{PARA_BREAK})\r?\n){1,200}?
1195
+ (?<!-)\breal(?!-)[ \t]+
1196
+ (?!(?:#{REAL_X_REAL_Y_EXCLUDED_NEXT_WORD})\b)
1197
+ (?!\1\b)
1198
+ (?>[a-z][\w'’-]*)\b
1199
+ /ix,
1200
+ message: 'Doubled "real" ("real X … real Y") repeats the intensifier for emphasis.',
1201
+ suggestion: "Cut one 'real,' or say what actually makes each thing real -- a name, a number, a log line.",
1202
+ examples_bad: [
1203
+ "If it can trigger real API calls or hold real credentials, it needs the same rigor.",
1204
+ "The demo used real customer data and exposed real financial records to the whole team.",
1205
+ "This isn't a mockup; it hits a real database and charges a real credit card.",
1206
+ "The incident caused real financial losses and real reputational damage to the company."
1207
+ ],
1208
+ examples_ok: [
1209
+ # The second "real" must name something the first didn't.
1210
+ "The system exposes a real risk, and that real risk must be tracked.",
1211
+ # A hyphen on either side makes it a compound, not two uses.
1212
+ "The dashboard shows real-time metrics and real-time alerts.",
1213
+ # "real world" is on the closed list even unhyphenated.
1214
+ "In the real world this rarely happens, and the real world rewards patience.",
1215
+ # The math sense, both nouns.
1216
+ "The equation has two real roots and no real numbers outside that range.",
1217
+ # "real user" (monitoring) and a hyphenated compound together.
1218
+ "The tool combines real user monitoring with real-time dashboards.",
1219
+ # Two more closed-list nouns.
1220
+ "She invested in real estate and lost real money.",
1221
+ # "non-real" is a negation, not a second use of the intensifier.
1222
+ "The audit flagged real deployment risk but no non-real anomalies.",
1223
+ # Only one "real" in the sentence.
1224
+ "This is a real problem worth solving.",
1225
+ # A paragraph break -- even one where the blank line holds only a
1226
+ # non-breaking space -- ends the sentence; the two paragraphs are
1227
+ # never joined into one hit.
1228
+ "This plan needs real signoff\n \nbefore it touches real production data."
1229
+ ],
1230
+ rationale: "\"Real\" is the plainest way to say a thing isn't fake or hypothetical, and it " \
1231
+ "only needs saying once a sentence -- naming it again in front of a second noun " \
1232
+ "doesn't add information, it repeats the reassurance. A person defending a claim " \
1233
+ "from two directions in the same breath writes the two facts and lets one " \
1234
+ "\"real\" cover both."
1235
+ ),
1000
1236
  Rule.new(
1001
1237
  id: "the-punchline-is",
1002
- category: "rhetorical-tic",
1238
+ category: "self-rating",
1003
1239
  severity: "warning",
1240
+ confidence: "high",
1004
1241
  # Same reveal, three nouns. "honest answer" and "honest version" join
1005
1242
  # "punchline" because they do the identical job: rate the sentence as
1006
1243
  # the candid one before the reader gets it. "short version" was tried
@@ -1025,8 +1262,9 @@ module Sloplint
1025
1262
  ),
1026
1263
  Rule.new(
1027
1264
  id: "worth-naming",
1028
- category: "rhetorical-tic",
1029
- severity: "info",
1265
+ category: "self-rating",
1266
+ severity: "warning",
1267
+ confidence: "medium",
1030
1268
  # Widened to optionally include a trailing "names" so the "naming
1031
1269
  # names" idiom is part of the matched text -- skip: checks the matched
1032
1270
  # text itself, and the tighter /\bworth\s+naming\b/ never captured
@@ -1063,8 +1301,9 @@ module Sloplint
1063
1301
  ),
1064
1302
  Rule.new(
1065
1303
  id: "worth-saying-plainly",
1066
- category: "rhetorical-tic",
1304
+ category: "self-rating",
1067
1305
  severity: "warning",
1306
+ confidence: "high",
1068
1307
  # Two branches, both sentence-initial.
1069
1308
  #
1070
1309
  # First: evaluative adjective + speech verb + manner adverb, all three
@@ -1116,8 +1355,9 @@ module Sloplint
1116
1355
  ),
1117
1356
  Rule.new(
1118
1357
  id: "not-nothing",
1119
- category: "rhetorical-tic",
1358
+ category: "false-concession",
1120
1359
  severity: "warning",
1360
+ confidence: "high",
1121
1361
  # Two branches: spelled-out copula with an optional captured subject, and
1122
1362
  # the contracted "X's not nothing". The optional subject + skip: is the
1123
1363
  # load-bearing idiom -- skip: only sees matched text, so the personal
@@ -1171,8 +1411,9 @@ module Sloplint
1171
1411
 
1172
1412
  Rule.new(
1173
1413
  id: "exact-exactly",
1174
- category: "rhetorical-tic",
1414
+ category: "self-rating",
1175
1415
  severity: "info",
1416
+ confidence: "medium",
1176
1417
  # Rewritten after 2.2M words of technical prose left ~111 of 125 hits
1177
1418
  # false. The shape was the problem, not the entries: the pattern matched
1178
1419
  # "exact" everywhere and subtracted an allow-list, and an open pattern
@@ -1278,8 +1519,9 @@ module Sloplint
1278
1519
  ),
1279
1520
  Rule.new(
1280
1521
  id: "load-bearing",
1281
- category: "rhetorical-tic",
1522
+ category: "borrowed-metaphor",
1282
1523
  severity: "warning",
1524
+ confidence: "high",
1283
1525
  # Two guards, both structural, over the same noun list so they can't
1284
1526
  # drift apart. Forward: a physical building part right after it is the
1285
1527
  # literal sense, checked with a negative lookahead. Backward: the
@@ -1328,8 +1570,9 @@ module Sloplint
1328
1570
  ),
1329
1571
  Rule.new(
1330
1572
  id: "intersection-of",
1331
- category: "rhetorical-tic",
1573
+ category: "borrowed-metaphor",
1332
1574
  severity: "warning",
1575
+ confidence: "high",
1333
1576
  # "at" is not load-bearing -- "explores the intersection of art and
1334
1577
  # technology" is the same move -- so the anchor is "the intersection of"
1335
1578
  # and the two guards carry the whole burden of separating the literal
@@ -1415,8 +1658,9 @@ module Sloplint
1415
1658
  ),
1416
1659
  Rule.new(
1417
1660
  id: "impact-verb",
1418
- category: "rhetorical-tic",
1661
+ category: "borrowed-metaphor",
1419
1662
  severity: "warning",
1663
+ confidence: "high",
1420
1664
  # "impact" and "impacts" are also nouns, so they only count as verbs
1421
1665
  # behind an auxiliary or a subject pronoun. One optional pronoun may sit
1422
1666
  # between the auxiliary and the verb ("does this impact the date");
@@ -1520,10 +1764,11 @@ module Sloplint
1520
1764
  Rule.new(
1521
1765
  id: "impact-noun-vague",
1522
1766
  category: "puffery",
1523
- severity: "warning",
1767
+ severity: "error",
1768
+ confidence: "high",
1524
1769
  # Only the puffed shapes: an intensity adjective, or make/have plus an
1525
- # article. "the impact of X" is left to impact-noun-bare, which sits at
1526
- # 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
1527
1772
  # "negative" stay out of the adjective list on purpose -- they name a
1528
1773
  # direction, which is more than the intensity words do. "statistically"
1529
1774
  # is pulled into the match as an optional leading word so skip: can see
@@ -1571,8 +1816,9 @@ module Sloplint
1571
1816
  ),
1572
1817
  Rule.new(
1573
1818
  id: "impact-noun-bare",
1574
- category: "rhetorical-tic",
1575
- severity: "info",
1819
+ category: "borrowed-metaphor",
1820
+ severity: "warning",
1821
+ confidence: "medium",
1576
1822
  # Two shapes, each needing its own anchor: a measuring verb in front, or
1577
1823
  # "of" behind. That is what keeps the collision sense clear without a
1578
1824
  # list of collision verbs -- "the impact crushed the front bumper" has
@@ -1609,14 +1855,15 @@ module Sloplint
1609
1855
  "Torque the bolts with an impact wrench.",
1610
1856
  "Will the impact be permanent?"
1611
1857
  ],
1612
- rationale: "This one sits at info because 'the impact of X on Y' is the ordinary word in " \
1613
- "research and policy writing, not a tell. Elsewhere it postpones the sentence: " \
1614
- "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."
1615
1861
  ),
1616
1862
  Rule.new(
1617
1863
  id: "thats-how-x",
1618
- category: "rhetorical-tic",
1864
+ category: "closer",
1619
1865
  severity: "warning",
1866
+ confidence: "high",
1620
1867
  pattern: /(?:\A|[.!?]\s+|\n\s*\n)\s*(?:that|this)(?:'s| is)\s+how\b/i,
1621
1868
  message: '"That\'s how…" opening a sentence is a stock LLM aphorism closer.',
1622
1869
  suggestion: "Cut the closer, or replace it with the concrete result you mean.",
@@ -1632,8 +1879,9 @@ module Sloplint
1632
1879
  ),
1633
1880
  Rule.new(
1634
1881
  id: "announced-takeaway",
1635
- category: "rhetorical-tic",
1882
+ category: "self-rating",
1636
1883
  severity: "warning",
1884
+ confidence: "high",
1637
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,
1638
1886
  message: "Colon-led takeaway label announces the lesson before making it.",
1639
1887
  suggestion: "Give the observation first; let the reader decide it's the takeaway.",
@@ -1649,8 +1897,9 @@ module Sloplint
1649
1897
  ),
1650
1898
  Rule.new(
1651
1899
  id: "is-is",
1652
- category: "rhetorical-tic",
1900
+ category: "cadence",
1653
1901
  severity: "warning",
1902
+ confidence: "high",
1654
1903
  # No anchor needed -- the doubled copula alone scored 0 across ~1.9M words.
1655
1904
  # The comma is allowed because "What it is, is a mystery" grates the same
1656
1905
  # way. Sentence and clause punctuation still block the weld ("what it is.
@@ -1685,8 +1934,9 @@ module Sloplint
1685
1934
 
1686
1935
  Rule.new(
1687
1936
  id: "earns-its-place",
1688
- category: "rhetorical-tic",
1937
+ category: "self-rating",
1689
1938
  severity: "warning",
1939
+ confidence: "high",
1690
1940
  # The possessive is the narrowing. "earned a place on the team" and
1691
1941
  # "earn a place in the final" are ordinary; "earns its place" is the
1692
1942
  # metaphor, a thing paying for the room it takes up.
@@ -1710,8 +1960,9 @@ module Sloplint
1710
1960
  ),
1711
1961
  Rule.new(
1712
1962
  id: "does-a-lot-of-work",
1713
- category: "rhetorical-tic",
1963
+ category: "self-rating",
1714
1964
  severity: "warning",
1965
+ confidence: "high",
1715
1966
  # Two arms, both narrowed away from the ordinary sense.
1716
1967
  #
1717
1968
  # "a lot of work" needs a locative ("here", "in that sentence") because
@@ -1743,8 +1994,9 @@ module Sloplint
1743
1994
  ),
1744
1995
  Rule.new(
1745
1996
  id: "failure-mode-here",
1746
- category: "rhetorical-tic",
1997
+ category: "borrowed-metaphor",
1747
1998
  severity: "warning",
1999
+ confidence: "high",
1748
2000
  # "here" is the whole narrowing, and it is doing a lot -- the bare "the
1749
2001
  # failure mode is" is ordinary engineering writing about real systems,
1750
2002
  # where the phrase means what it says. The deictic is what marks the
@@ -1770,8 +2022,9 @@ module Sloplint
1770
2022
  ),
1771
2023
  Rule.new(
1772
2024
  id: "thats-the-tension",
1773
- category: "rhetorical-tic",
2025
+ category: "closer",
1774
2026
  severity: "warning",
2027
+ confidence: "high",
1775
2028
  # Sentence-initial, only two nouns, and the noun must end the clause.
1776
2029
  #
1777
2030
  # "tradeoff" and "catch" were tried and cut: both are ordinary English
@@ -1804,8 +2057,9 @@ module Sloplint
1804
2057
  ),
1805
2058
  Rule.new(
1806
2059
  id: "right-up-until",
1807
- category: "rhetorical-tic",
2060
+ category: "closer",
1808
2061
  severity: "warning",
2062
+ confidence: "high",
1809
2063
  # The intensifier is the tell, not the reversal. "It works until it
1810
2064
  # doesn't" is an old human idiom and stays clean; stacking "right up"
1811
2065
  # in front of it is the model's version, and the two together are two
@@ -1830,8 +2084,9 @@ module Sloplint
1830
2084
  ),
1831
2085
  Rule.new(
1832
2086
  id: "two-things-true",
1833
- category: "rhetorical-tic",
1834
- severity: "warning",
2087
+ category: "false-concession",
2088
+ severity: "error",
2089
+ confidence: "high",
1835
2090
  # Closed phrase, no anchor needed. The optional "both" and the optional
1836
2091
  # "at once" tail are the two ways the sentence is padded; the count word
1837
2092
  # is fixed at two, because "three things can be true" is someone
@@ -1856,8 +2111,9 @@ module Sloplint
1856
2111
  ),
1857
2112
  Rule.new(
1858
2113
  id: "notice-what-there",
1859
- category: "rhetorical-tic",
2114
+ category: "reader-address",
1860
2115
  severity: "warning",
2116
+ confidence: "high",
1861
2117
  # The self-referential half of the attention cue: the sentence points
1862
2118
  # at the writing rather than at anything in the world. Two frames, both
1863
2119
  # sentence-initial -- "notice what X did there" and the bare "read that
@@ -1885,8 +2141,9 @@ module Sloplint
1885
2141
  ),
1886
2142
  Rule.new(
1887
2143
  id: "notice-what",
1888
- category: "rhetorical-tic",
2144
+ category: "reader-address",
1889
2145
  severity: "info",
2146
+ confidence: "medium",
1890
2147
  # The ambiguous half of the pair, and it ships at info because the
1891
2148
  # sentence-initial imperative is also how people point at something
1892
2149
  # real: "Notice what happens around Q3", "Notice what is not on the
@@ -1917,8 +2174,9 @@ module Sloplint
1917
2174
  ),
1918
2175
  Rule.new(
1919
2176
  id: "none-of-this-is-to-say",
1920
- category: "rhetorical-tic",
1921
- severity: "warning",
2177
+ category: "false-concession",
2178
+ severity: "error",
2179
+ confidence: "high",
1922
2180
  # Only the "none of" form. Every neighbouring phrasing is ordinary
1923
2181
  # English by an order of magnitude -- "which is not to say", "this is
1924
2182
  # not to say", "that's not to say" -- and admitting any of them would
@@ -1944,10 +2202,11 @@ module Sloplint
1944
2202
  ),
1945
2203
  Rule.new(
1946
2204
  id: "if-im-being-honest",
1947
- category: "rhetorical-tic",
1948
- severity: "info",
2205
+ category: "false-concession",
2206
+ severity: "warning",
2207
+ confidence: "medium",
1949
2208
  # The "being honest" frame only. Plain "to be honest" and "I'll be
1950
- # honest" are how people talk and are excluded. info, not warning:
2209
+ # honest" are how people talk and are excluded. Medium confidence:
1951
2210
  # writers really do say this out loud.
1952
2211
  pattern: /\bif\s+(?:I['’]?m|I\s+am|we['’]?re|we\s+are)\s+(?:being\s+)?honest\b
1953
2212
  |\bhonestly,\s+the\s+(?:answer|truth)\b/ix,
@@ -1969,8 +2228,9 @@ module Sloplint
1969
2228
  ),
1970
2229
  Rule.new(
1971
2230
  id: "honestly",
1972
- category: "rhetorical-tic",
2231
+ category: "self-rating",
1973
2232
  severity: "warning",
2233
+ confidence: "high",
1974
2234
  # The manner adverb, the way "cleanly" is the manner adverb: hung on a
1975
2235
  # subject that cannot be honest. Two guards, no verb list.
1976
2236
  #
@@ -2020,8 +2280,9 @@ module Sloplint
2020
2280
  ),
2021
2281
  Rule.new(
2022
2282
  id: "honest-x",
2023
- category: "rhetorical-tic",
2283
+ category: "self-rating",
2024
2284
  severity: "warning",
2285
+ confidence: "high",
2025
2286
  # The noun list is short on purpose, and the words left out are the
2026
2287
  # point. "An honest answer", "an honest assessment" and "an honest
2027
2288
  # account" are ordinary English about people -- six hits between them in
@@ -2035,8 +2296,7 @@ module Sloplint
2035
2296
  # superlative belongs to most-honest-x alone.
2036
2297
  pattern: /\b(?:an|the|one|this|that)[ \t]+(?:(?!most\b|more\b)\w+[ \t]+)?
2037
2298
  honest[ \t]+(?:\w+[ \t]+)?
2038
- (?:comparison|framing|formulation|accounting|abstraction|mapping
2039
- |through-line)\b/ix,
2299
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|comparison|accounting|through-line)\b/ix,
2040
2300
  message: '"An honest comparison / the honest framing" praises the writing, not the thing.',
2041
2301
  suggestion: "Cut the adjective and make the comparison; the reader decides if it is honest.",
2042
2302
  examples_bad: [
@@ -2064,8 +2324,9 @@ module Sloplint
2064
2324
  ),
2065
2325
  Rule.new(
2066
2326
  id: "most-honest-x",
2067
- category: "rhetorical-tic",
2068
- severity: "warning",
2327
+ category: "self-rating",
2328
+ severity: "error",
2329
+ confidence: "high",
2069
2330
  # The superlative frame carries the tell on its own, so this noun list is
2070
2331
  # wider than honest-x's -- "the most honest assessment" is self-ranking
2071
2332
  # in a way "an honest assessment" is not. The list still has to keep out
@@ -2073,9 +2334,10 @@ module Sloplint
2073
2334
  # politician" is, so no human nouns go in. Nothing in either corpus
2074
2335
  # matches. "way to" gets its own branch, mirroring cleanest-x.
2075
2336
  pattern: /\bmost[ \t]+honest[ \t]+(?:(?!way\b)\w+[ \t]+){0,2}
2076
- (?:comparison|framing|formulation|accounting|assessment|appraisal
2077
- |reading|account|answer|version|summary|take|signal|abstraction
2078
- |mapping|through-line)\b
2337
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|comparison
2338
+ |accounting|assessment|appraisal
2339
+ |reading|account|answer|version|summary|take|signal
2340
+ |through-line)\b
2079
2341
  |\bmost[ \t]+honest[ \t]+way[ \t]+to[ \t]+
2080
2342
  (?:say|put|frame|state|describe|phrase|think[ \t]+about)\b/ix,
2081
2343
  message: '"The most honest framing…" ranks your own claim for the reader.',
@@ -2099,14 +2361,14 @@ module Sloplint
2099
2361
  ),
2100
2362
  Rule.new(
2101
2363
  id: "genuinely",
2102
- category: "rhetorical-tic",
2364
+ category: "self-rating",
2103
2365
  severity: "info",
2366
+ confidence: "low",
2104
2367
  # Off by default. There is no narrowing here: the word is ordinary
2105
2368
  # English at every frequency we measured, and the difference between
2106
2369
  # the tell and the real use is whether a contrast exists in the
2107
2370
  # surrounding argument, which no regex can see. Selectable when a
2108
2371
  # writer wants every occurrence listed back to them.
2109
- default_on: false,
2110
2372
  pattern: /\bgenuinely\b/i,
2111
2373
  message: '"Genuinely" asserts sincerity instead of earning it (heuristic; high false-positive).',
2112
2374
  suggestion: "Cut the adverb. If the sentence needs it, the claim is doing the work.",
@@ -2126,10 +2388,78 @@ module Sloplint
2126
2388
  "nominally free -- and nothing in the sentence marks which use is which, " \
2127
2389
  "which is why this one runs only when you ask for it."
2128
2390
  ),
2391
+ Rule.new(
2392
+ id: "actually-not-x",
2393
+ category: "false-correction",
2394
+ severity: "warning",
2395
+ confidence: "high",
2396
+ # Two markers that each correct the reader, doubled up in one clause:
2397
+ # the adverb and the trailing "…, not X". Bare "actually" is not the
2398
+ # tell and is not matched, because "the build actually failed on the
2399
+ # second run" names which run and that is a fact with something behind
2400
+ # it to check.
2401
+ #
2402
+ # The narrowing is one structural constraint, not a word list: the
2403
+ # comma in front of "not" has to be the first comma of its clause and
2404
+ # has to follow a word. A writer correcting an assumption the reader
2405
+ # does hold names it first, and that setup is either a fronted clause
2406
+ # with a comma after it ("Despite the name, …") or a parenthetical
2407
+ # whose closing bracket the comma follows. No earlier comma means
2408
+ # nothing in the clause set the alternative up. A clause starts at the
2409
+ # beginning of the text, at a full stop, question mark, exclamation
2410
+ # mark, semicolon or colon, or at a line break.
2411
+ #
2412
+ # Nothing crosses a line break, so the rule cannot weld two rows of a
2413
+ # table or two items of a list into one correction. The deliberate
2414
+ # misses: a hard-wrapped correction, and a correction whose clause
2415
+ # carries an earlier comma of any kind, an apposition ("The report,
2416
+ # filed late, says the id is actually a byte string, not text.")
2417
+ # included. No pattern tells an apposition from a fronted setup, and
2418
+ # the rule would rather say nothing than guess.
2419
+ pattern: /(?:\A|(?<=[.;:!?\n]))[^.;:!?,\n]{0,200}\K
2420
+ \bactually\b[^.;:!?,\n]{0,120}(?<=[\p{Word}]),[ \t]*not\b/ix,
2421
+ message: '"Actually …, not X" corrects an assumption the text never offered.',
2422
+ suggestion: "State the fact plainly, or name the belief the correction answers.",
2423
+ examples_bad: [
2424
+ "The disclosure actually covered two incidents, not one.",
2425
+ "The identifier is actually a byte string, not text.",
2426
+ "It actually reads the file at startup, not on the first request.",
2427
+ # A colon opens a clause, so the correction after it is still the tell.
2428
+ "Note: the endpoint actually returns two fields, not one."
2429
+ ],
2430
+ examples_ok: [
2431
+ # The bare adverb, which names which run failed. Not matched.
2432
+ "The build actually failed on the second run.",
2433
+ # The setup sits in a fronted clause, so the correction answers
2434
+ # something the reader was given. The earlier comma drops both.
2435
+ "Despite the name, the identifier is actually a byte string, not text.",
2436
+ "Although the name suggests otherwise, it is actually a byte string, not text.",
2437
+ # The correction on its own, with no adverb in front of it.
2438
+ "The run that failed was the second, not the first.",
2439
+ # Negation in front of the adverb with no trailing correction. This
2440
+ # was the other candidate shape; every real instance of it carried
2441
+ # its own setup ("normally reserved but not actually registered"),
2442
+ # so it was rejected, and this fixture pins the rejection.
2443
+ "The provider was not actually billed for the usage.",
2444
+ # The correction's comma closes a parenthesis, and the assumption
2445
+ # being corrected is inside it.
2446
+ "The party actually sending the mail (which we assumed was the relay), not the sender.",
2447
+ # Two rows of a table, not a wrapped sentence. The rule never crosses
2448
+ # a line break, so it cannot join them.
2449
+ "Actually running processes\nThreads, not processes"
2450
+ ],
2451
+ rationale: "The adverb and the trailing \"not X\" both correct the reader, and a writer " \
2452
+ "needs one or the other, never both. Doubling them marks a contrast against " \
2453
+ "an alternative the reader was never offered: nothing said one incident, so " \
2454
+ "\"not one\" answers nobody. The setup can also sit in the sentence before, " \
2455
+ "which no pattern sees, so a flag on a correction that a previous sentence " \
2456
+ "genuinely set up is the known cost."
2457
+ ),
2129
2458
  Rule.new(
2130
2459
  id: "and-thats-fine",
2131
- category: "rhetorical-tic",
2132
- severity: "info",
2460
+ category: "false-concession",
2461
+ severity: "warning",
2462
+ confidence: "medium",
2133
2463
  # Three narrowings, and the rule needs all of them. "and" is required:
2134
2464
  # bare "that's fine" is a reply people write constantly. The match must
2135
2465
  # open a sentence and close it, so the concessive clause -- "and that's
@@ -2153,8 +2483,9 @@ module Sloplint
2153
2483
  ),
2154
2484
  Rule.new(
2155
2485
  id: "and-nothing-else",
2156
- category: "rhetorical-tic",
2486
+ category: "closer",
2157
2487
  severity: "warning",
2488
+ confidence: "high",
2158
2489
  # This rule is deliberately wide, and the cost is known. There is no verb
2159
2490
  # list and no imperative requirement, so the only narrowing is structural
2160
2491
  # -- which means the pattern cannot tell a leaked instruction from the
@@ -2212,8 +2543,9 @@ module Sloplint
2212
2543
  ),
2213
2544
  Rule.new(
2214
2545
  id: "nothing-else-frag",
2215
- category: "rhetorical-tic",
2546
+ category: "closer",
2216
2547
  severity: "warning",
2548
+ confidence: "high",
2217
2549
  # The same exclusion as its own sentence: "Return the JSON. Nothing
2218
2550
  # else." Built on the no-x-no-y-frag template -- the fragment must start
2219
2551
  # at a sentence boundary and the separator is at most two spaces or one
@@ -2249,18 +2581,18 @@ module Sloplint
2249
2581
  # letter guard is there to exclude.
2250
2582
  "and so on; nothing more."
2251
2583
  ],
2252
- rationale: "The fragment form of the same leaked instruction, and it reads the same way: " \
2253
- "a second sentence that only says the first one was complete. It ships at " \
2254
- "warning rather than info because the capital letter and the whole-sentence " \
2255
- "requirement keep it off the continuation sense, which is where ordinary prose " \
2256
- "puts the phrase: one hit in 1.92M words of public-domain prose and one in " \
2257
- "461k words of pre-2022 Hacker News."
2584
+ rationale: "The fragment form of the same leaked instruction, and it reads the same " \
2585
+ "way: a second sentence that only says the first one was complete. The " \
2586
+ "capital letter and the whole-sentence requirement keep it off the " \
2587
+ "continuation sense, which is where ordinary prose puts the phrase: one hit " \
2588
+ "in 1.92M words of public-domain prose and one in 461k words of pre-2022 " \
2589
+ "Hacker News."
2258
2590
  ),
2259
- # ── puffery ───────────────────────────────────────────────────────────
2260
2591
  Rule.new(
2261
2592
  id: "puffery-words",
2262
2593
  category: "puffery",
2263
- severity: "warning",
2594
+ severity: "error",
2595
+ confidence: "high",
2264
2596
  # "nestled" alone is the literal verb as often as the puffery sense --
2265
2597
  # a head nestling against a shoulder, a kitten nestling into a blanket
2266
2598
  # -- so it requires a following in/among/between, the same shape the
@@ -2296,7 +2628,8 @@ module Sloplint
2296
2628
  Rule.new(
2297
2629
  id: "stands-serves-as",
2298
2630
  category: "puffery",
2299
- severity: "info",
2631
+ severity: "warning",
2632
+ confidence: "medium",
2300
2633
  pattern: /\b(?:stands|serves)\s+as\b|\bis\s+a\s+(?:testament|reminder)\s+to\b/i,
2301
2634
  message: '"stands/serves as", "is a testament/reminder to" is puffed AI framing.',
2302
2635
  suggestion: "Say what it does, not what it 'stands as'.",
@@ -2307,7 +2640,8 @@ module Sloplint
2307
2640
  Rule.new(
2308
2641
  id: "vital-role",
2309
2642
  category: "puffery",
2310
- severity: "warning",
2643
+ severity: "error",
2644
+ confidence: "high",
2311
2645
  pattern: /\bplays?\s+a\s+(?:vital|crucial|pivotal|significant|key|central)\s+role\b/i,
2312
2646
  message: '"plays a vital/crucial/… role" is a stock AI puffery phrase.',
2313
2647
  suggestion: "State the specific role or effect instead.",
@@ -2318,7 +2652,8 @@ module Sloplint
2318
2652
  Rule.new(
2319
2653
  id: "underscores-highlights",
2320
2654
  category: "puffery",
2321
- severity: "info",
2655
+ severity: "warning",
2656
+ confidence: "medium",
2322
2657
  # "underscored/underscoring" are unambiguously the verb and flag bare.
2323
2658
  # "underscore/underscores" is also the character noun (a leading
2324
2659
  # underscore, snake_case docs), so those forms require a following
@@ -2359,15 +2694,16 @@ module Sloplint
2359
2694
  "Numbers accept underscores\n\nThe next section covers floats."
2360
2695
  ],
2361
2696
  rationale: "Models reach for 'underscore' as an all-purpose emphasis verb -- findings " \
2362
- "underscore, outages underscore -- asserting significance without earning it. " \
2363
- "Sincere journalistic and academic use exists, hence info: a flag means the " \
2364
- "move is present, not that it's slop. The character noun never takes the " \
2365
- "verb's frame and stays out."
2697
+ "underscore, outages underscore -- asserting significance without earning " \
2698
+ "it. Sincere journalistic and academic use exists: a flag means the move is " \
2699
+ "present, not that it's slop. The character noun never takes the verb's " \
2700
+ "frame and stays out."
2366
2701
  ),
2367
2702
  Rule.new(
2368
2703
  id: "rich-tapestry",
2369
2704
  category: "puffery",
2370
- severity: "warning",
2705
+ severity: "error",
2706
+ confidence: "high",
2371
2707
  pattern: /\brich\s+tapestry\b|\btapestry\s+of\b/i,
2372
2708
  message: '"rich tapestry"/"tapestry of" is a signature AI cliché.',
2373
2709
  suggestion: "Cut the metaphor; name the actual things.",
@@ -2376,11 +2712,11 @@ module Sloplint
2376
2712
  rationale: "'tapestry of' is one of the most reliable single-phrase model tells."
2377
2713
  ),
2378
2714
 
2379
- # ── structure ─────────────────────────────────────────────────────────
2380
2715
  Rule.new(
2381
2716
  id: "not-just-x-but-y",
2382
- category: "structure",
2717
+ category: "false-correction",
2383
2718
  severity: "warning",
2719
+ confidence: "high",
2384
2720
  # Two branches, both anchored on an explicit escalation word. (1) The
2385
2721
  # copula escalation: "is not just/only/merely/simply/solely A … but B".
2386
2722
  # (2) "not because A, but because B". The escalation word is what makes
@@ -2424,10 +2760,11 @@ module Sloplint
2424
2760
  ),
2425
2761
  Rule.new(
2426
2762
  id: "not-x-but-y",
2427
- category: "structure",
2428
- severity: "info",
2763
+ category: "false-correction",
2764
+ severity: "warning",
2765
+ confidence: "medium",
2429
2766
  # The bare corrective: "is not A but B", no escalation word, comma or no
2430
- # comma. Ships at info because the line between a corrective ("not an
2767
+ # comma. Medium confidence, because the line between a corrective ("not an
2431
2768
  # accident but a strategy") and an ordinary concession ("not warm but the
2432
2769
  # fire helped") is syntactic, and a regex cannot see syntax. What is here
2433
2770
  # is a set of cheap narrowings that cut the worst of the noise: A is
@@ -2441,7 +2778,8 @@ module Sloplint
2441
2778
  # ("was not warm but the fire helped") or a bare lexical verb ("was not
2442
2779
  # perfect but got us there"), because neither is distinguishable from the
2443
2780
  # corrective by surface form. That is the cost of the rule and the reason
2444
- # it is info: the agent reading the flag has the context to judge, and
2781
+ # its confidence is medium: the agent reading the flag has the context
2782
+ # to judge, and
2445
2783
  # should. Do not chase these by growing the B-list -- every word added
2446
2784
  # silently narrows recall with nothing pinning it.
2447
2785
  pattern: /(?:\bis|\bare|\bwas|\bwere|\bisn['’]t|\baren['’]t|\bwasn['’]t|\bweren['’]t|
@@ -2488,15 +2826,16 @@ module Sloplint
2488
2826
  "escalation word dropped, and models reach for it constantly. It is the " \
2489
2827
  "noisiest rule in the catalog by design: a loose version scored 174 hits " \
2490
2828
  "before narrowing, cut to 15 here -- but not all 15 are correctives. Some " \
2491
- "are concessions with an elided subject (Walden's " \
2492
- "'It was not lonely, but made all the earth lonely beneath it'), which no " \
2493
- "surface pattern can tell apart from the real thing. Hence info: a flag here " \
2494
- "means 'this has the shape', not 'this is slop'."
2829
+ "are concessions with an elided subject (Walden's 'It was not lonely, but " \
2830
+ "made all the earth lonely beneath it'), which no surface pattern can tell " \
2831
+ "apart from the real thing. A flag here means 'this has the shape', not " \
2832
+ "'this is slop'."
2495
2833
  ),
2496
2834
  Rule.new(
2497
2835
  id: "isnt-x-its-y",
2498
- category: "structure",
2499
- severity: "info",
2836
+ category: "false-correction",
2837
+ severity: "warning",
2838
+ confidence: "medium",
2500
2839
  # The corrective with the conjunction dropped: one clause rejects a
2501
2840
  # description, the next supplies the replacement through a second copula
2502
2841
  # ("It isn't the tool. It's the habit."). Every copula tense is covered,
@@ -2512,7 +2851,8 @@ module Sloplint
2512
2851
  # guards cut a loose version from 19 hits to 4 over 889k words of
2513
2852
  # 19th-century fiction.
2514
2853
  #
2515
- # What survives is the reason for info. An adjective outside the list
2854
+ # What survives is the reason the confidence is medium. An adjective
2855
+ # outside the list
2516
2856
  # still slips through, and some hits are the same shape written by a
2517
2857
  # person -- "was not the wife; it was the children" is Conan Doyle. A flag
2518
2858
  # here says the sentence has the frame, not that a model wrote it.
@@ -2584,13 +2924,14 @@ module Sloplint
2584
2924
  "one, so the sentence sounds like a correction while correcting nobody. It " \
2585
2925
  "is 'not A but B' with the conjunction dropped and the second half promoted " \
2586
2926
  "to its own clause, which is the form models reach for most. Ordinary prose " \
2587
- "contrasts two things this way too, so the rule ships at info: it reports " \
2588
- "the shape, not a verdict."
2927
+ "contrasts two things this way too, so the rule reports the shape, not a " \
2928
+ "verdict."
2589
2929
  ),
2590
2930
  Rule.new(
2591
2931
  id: "not-by-x-but-by-y",
2592
- category: "structure",
2593
- severity: "info",
2932
+ category: "false-correction",
2933
+ severity: "warning",
2934
+ confidence: "medium",
2594
2935
  # The corrective built on a repeated preposition: "not by A, but by B",
2595
2936
  # "not from A but from B". The copula rules above cannot see it because
2596
2937
  # nothing precedes "not" but the verb or a dash, so the anchor here is
@@ -2631,22 +2972,28 @@ module Sloplint
2631
2972
  "We won not by luck\n\nBut by then it hardly mattered."
2632
2973
  ],
2633
2974
  rationale: "The 'not A but B' corrective with the copula swapped for a repeated " \
2634
- "preposition, which is how a model corrects a claim about means or " \
2635
- "cause ('not by luck, but by design'). People write it too -- Thoreau " \
2636
- "and Melville both lean on it -- so the rule ships at info. It reports " \
2637
- "the shape; a human reader decides whether it earned its place."
2975
+ "preposition, which is how a model corrects a claim about means or cause " \
2976
+ "('not by luck, but by design'). People write it too -- Thoreau and Melville " \
2977
+ "both lean on it -- and the rule reports the shape; a human reader decides " \
2978
+ "whether it earned its place."
2638
2979
  ),
2639
2980
  Rule.new(
2640
2981
  id: "rule-of-three",
2641
- category: "structure",
2982
+ category: "cadence",
2642
2983
  severity: "info",
2643
- default_on: false,
2984
+ confidence: "low",
2644
2985
  pattern: /\b[\w'-]+,\s+[\w'-]+,\s+(?:and\s+)?[\w'-]+[.!?]/,
2645
- message: "Three parallel comma items closing a sentence (heuristic; high false-positive).",
2986
+ message: "Three single words in a comma series closing a sentence (heuristic; high false-positive).",
2646
2987
  suggestion: "Fine in moderation; watch for the AI habit of ending on triplets.",
2647
2988
  examples_bad: ["It was fast, cheap, and simple."],
2648
- examples_ok: ["We met on Tuesday afternoon."],
2649
- rationale: "Rule-of-three endings are a model habit, but humans use them too — off by default."
2989
+ examples_ok: [
2990
+ "We met on Tuesday afternoon.",
2991
+ # Phrasal items do not match; the closing two slots take one word each.
2992
+ "It was very fast, very cheap, and very simple."
2993
+ ],
2994
+ rationale: "Rule-of-three endings are a model habit, but humans use them too — off by default. " \
2995
+ "The closing two items must be single words; a triad of phrases is three ordinary " \
2996
+ "list items to a regex, so those are left unflagged rather than guessed at."
2650
2997
  ),
2651
2998
  # clause-triad-then was cut. The pattern (comma-clause, comma-clause,
2652
2999
  # "then" clause) had no way to require the clauses actually be parallel
@@ -2660,8 +3007,9 @@ module Sloplint
2660
3007
  # CLAUDE.md: some tells can't be regexes; this was one.
2661
3008
  Rule.new(
2662
3009
  id: "everyone-nobody",
2663
- category: "structure",
3010
+ category: "cadence",
2664
3011
  severity: "warning",
3012
+ confidence: "high",
2665
3013
  # The comma-spliced antithesis on quantifier subjects: "Everyone wants
2666
3014
  # the dashboard, nobody maintains it." One clause opens on
2667
3015
  # everyone/everybody, the other on nobody/no one/none or "one N"
@@ -2733,8 +3081,9 @@ module Sloplint
2733
3081
  ),
2734
3082
  Rule.new(
2735
3083
  id: "np-fragment-and",
2736
- category: "structure",
2737
- severity: "info",
3084
+ category: "cadence",
3085
+ severity: "warning",
3086
+ confidence: "medium",
2738
3087
  # A whole sentence that is two noun phrases and an "and": "A named owner
2739
3088
  # and a quarterly review." It is the fix half of a model's
2740
3089
  # problem-then-fix pair, with the verb left for the reader to supply.
@@ -2745,7 +3094,7 @@ module Sloplint
2745
3094
  # anywhere in it, contractions included, so "A man and a woman were
2746
3095
  # there." and "A man and a woman aren't here." never match.
2747
3096
  #
2748
- # Ships at info, and this is why: a lexical verb is invisible to the
3097
+ # Medium confidence, and this is why: a lexical verb is invisible to the
2749
3098
  # pattern, so "A car and a truck collided." has the same shape and
2750
3099
  # flags. The corpora say that sentence is rare (one hit in 1.25M words
2751
3100
  # of public-domain prose, most of it narrative), but it is a complete
@@ -2788,8 +3137,9 @@ module Sloplint
2788
3137
  ),
2789
3138
  Rule.new(
2790
3139
  id: "quip-question",
2791
- category: "structure",
3140
+ category: "reader-address",
2792
3141
  severity: "info",
3142
+ confidence: "medium",
2793
3143
  # The verbless question that opens a pitch: "No invite?", "New to the
2794
3144
  # tool?", "Still stuck?", "Ready to start?". It must start a sentence,
2795
3145
  # open on one of a short list of words, run one to four more words, and
@@ -2835,8 +3185,9 @@ module Sloplint
2835
3185
  ),
2836
3186
  Rule.new(
2837
3187
  id: "mic-drop-closer",
2838
- category: "structure",
3188
+ category: "cadence",
2839
3189
  severity: "info",
3190
+ confidence: "medium",
2840
3191
  # The kicker: a sentence of at least sixty characters, then a closer of
2841
3192
  # two to eight words that ends the paragraph and opens on a quantifier
2842
3193
  # ("Nothing here needs a new login.", "Most teams end up
@@ -2859,7 +3210,7 @@ module Sloplint
2859
3210
  # position." The quantifiers carry the tell; the demonstratives are
2860
3211
  # ordinary, and no examples_bad used one.
2861
3212
  #
2862
- # Ships at info, and the rationale says why: people end paragraphs this
3213
+ # Medium confidence, and the rationale says why: people end paragraphs this
2863
3214
  # way too, at about 150 per million words on Hacker News. One is
2864
3215
  # nothing. A draft where most paragraphs end this way is the tell, and
2865
3216
  # an agent that sees the flag repeat should read the family as a
@@ -2872,7 +3223,7 @@ module Sloplint
2872
3223
  # long unpunctuated stretches (the Columbia report has an 864-character
2873
3224
  # one) sent this into catastrophic backtracking, 62 seconds for a 2 KB
2874
3225
  # window and no completion on the 1.1 MB document.
2875
- pattern: /(?:^|(?<=[.!?])[ \t]{1,2})(?>(?:[^.!?\n\s]|(?<![ \t])[ \t]{1,2}(?![ \t])|\r?\n(?!\s*\n)[ \t]*){60,})[.!?][ \t]{1,2}\K
3226
+ pattern: /#{SENTENCE_OF_SIXTY_CHARACTERS_ENDING_IN_PUNCTUATION_AND_SPACE}\K
2876
3227
  (?:Nothing|Most|None|Everything|Everyone|Nobody|Then|Neither|Both)
2877
3228
  (?:,?(?:[ \t]|\r?\n(?!\s*\n))+[\w'’-]+){1,7}[.!?](?=[ \t]*(?:\r?\n[ \t]*(?:\r?\n|\z)|\z))/x,
2878
3229
  message: "A short quantifier-led closer after a long sentence is the AI kicker.",
@@ -2915,10 +3266,96 @@ module Sloplint
2915
3266
  "where the flag repeats paragraph after paragraph should be read as a " \
2916
3267
  "warning, and the fix is usually to delete the closer outright."
2917
3268
  ),
3269
+ Rule.new(
3270
+ id: "bare-auxiliary-closer",
3271
+ category: "cadence",
3272
+ severity: "info",
3273
+ confidence: "medium",
3274
+ # The same long-sentence-then-short-closer shape as mic-drop-closer,
3275
+ # but the tell lives in the verb, not the subject. mic-drop-closer's
3276
+ # closer opens on a quantifier and keeps a full verb with its object
3277
+ # ("Nothing here needs a new login."). This one carries no verb at
3278
+ # all: the closer's verb phrase has been elided down to the bare
3279
+ # auxiliary that would have introduced it, and the object it
3280
+ # promised never arrives -- "They found an exposed dashboard and
3281
+ # asked the agent running on it to hand over its own key. The agent
3282
+ # did." Because the tell is the missing verb, this rule needs no
3283
+ # subject list the way mic-drop-closer does, and "That", "This" and
3284
+ # "It" are not excluded here the way they are there: "This completes
3285
+ # the roughing operations." ends on a full verb with an object and
3286
+ # never matches (there is no auxiliary at the sentence's end), while
3287
+ # "This did." would, on the same subject mic-drop-closer had to bar.
3288
+ #
3289
+ # The closer is a one-to-three word subject running straight into the
3290
+ # bare auxiliary and a period, and, same as mic-drop-closer, it must
3291
+ # be the last thing in the paragraph -- a blank line or the end of
3292
+ # the text follows it. A closing quotation mark after the period is
3293
+ # not blank, so quoted dialogue never satisfies this and is excluded
3294
+ # the same way short-run excludes it. A negative lookahead drops any
3295
+ # closer that still holds "what", "that", "which", "who", "why" or
3296
+ # "how", because those mark a subordinate clause supplying its own
3297
+ # complement rather than an elided one: "Nobody knew who did." asks
3298
+ # "who did it", which the subject cap alone does not catch, since
3299
+ # "Nobody knew who" is itself only three words.
3300
+ #
3301
+ # Reuses mic-drop-closer's long-sentence prefix rather than pasting a
3302
+ # second copy of it; see that constant's comment for the atomic group
3303
+ # that keeps the scan linear.
3304
+ #
3305
+ # The negated form is bolted on with "n't" for every auxiliary except
3306
+ # two irregular ones: "can" already ends in n, so its negation is
3307
+ # "can't", not "cann't", and "will" negates to the different stem
3308
+ # "won't" rather than "willn't". Both are spelled out rather than
3309
+ # built by suffix, so the two most common contractions in the list
3310
+ # are not silently unmatchable.
3311
+ pattern: /#{SENTENCE_OF_SIXTY_CHARACTERS_ENDING_IN_PUNCTUATION_AND_SPACE}\K
3312
+ (?![^.!?\n]*\b(?:what|that|which|who|why|how)\b)
3313
+ [A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[ \t]+
3314
+ (?:(?:did|does|do|was|were|is|are|had|has|could|would|should|might|must)(?:n['’]t)?
3315
+ |can(?:['’]t)?|will|won['’]t)\.
3316
+ (?=[ \t]*(?:\r?\n[ \t]*(?:\r?\n|\z)|\z))/x,
3317
+ message: "A closer that ends on a bare auxiliary is the AI verb-phrase-ellipsis kicker.",
3318
+ suggestion: "Cut the closer, or say what actually happened.",
3319
+ examples_bad: [
3320
+ "They found an exposed dashboard and asked the agent running on it to hand over its own key. The agent did.",
3321
+ "The team spent three weeks arguing about whether the migration was worth the downtime it would cost the on-call rotation. It wasn't.",
3322
+ "The reviewer asked whether a contractor with read access to the shared drive could still open the finance folder after the offboarding ran. She could.",
3323
+ "He asked whether the on-call engineer had actually paged the second responder before escalating past the fifteen-minute window. She had.",
3324
+ # The two irregular negated forms, pinned so a later rewrite of the
3325
+ # suffix can't quietly drop them again.
3326
+ "They double-checked whether the fallback path could still serve read traffic once the primary region failed over during the drill. It can't.",
3327
+ "The team hoped the migration window would close before the seasonal freight peak began overwhelming the warehouse systems. It won't."
3328
+ ],
3329
+ examples_ok: [
3330
+ # Not the end of the paragraph.
3331
+ "They found an exposed dashboard and asked the agent running on it to hand over its own key. The agent did. We logged the incident and rotated the key within the hour.",
3332
+ # A subordinate clause, not an elided one -- caught by the wh-word guard.
3333
+ "The team spent three weeks arguing about whether the migration was worth the downtime it would cost the on-call rotation. That is what it did.",
3334
+ # No long sentence in front of it.
3335
+ "The agent did.",
3336
+ # A subject longer than three words -- the cap, not the wh-guard, excludes it.
3337
+ "The engineer who had been paged in the middle of the night finally agreed with what the on-call reviewer had been saying for the better part of an hour about the rollback plan. The whole team already did.",
3338
+ # A full verb with an object, not a bare auxiliary.
3339
+ "Each step already passed local review before it reached the pipeline that runs on every push to the shared branch. It worked.",
3340
+ # A bare demonstrative closing a step is how procedural writing ends
3341
+ # a paragraph, and it ends on a full verb, not an auxiliary. Wording
3342
+ # follows Turning and Boring (1919) and Aviation Engines (1917),
3343
+ # both public domain by date; mic-drop-closer carries the same two
3344
+ # examples for the same reason.
3345
+ "The cutting tools are set to the dimensions required for the finished work, and the stops are locked. This completes the roughing operations.",
3346
+ "The magneto is protected from oil and grit by a cover that is easy to remove for service. This means prolonged life for the magneto."
3347
+ ],
3348
+ rationale: "The closer withholds exactly the thing the long setup built toward -- the " \
3349
+ "verb and its object are gone, and only the bare confirmation that something " \
3350
+ "happened is left standing. Plenty of ordinary writing drops the verb the " \
3351
+ "same way when it confirms an expectation, and one flag proves nothing; a " \
3352
+ "draft where it repeats is the tell."
3353
+ ),
2918
3354
  Rule.new(
2919
3355
  id: "short-run",
2920
- category: "structure",
3356
+ category: "cadence",
2921
3357
  severity: "info",
3358
+ confidence: "medium",
2922
3359
  # Three consecutive sentences of thirty characters or fewer, each
2923
3360
  # opening on a letter and closing on a full stop, with no quotation
2924
3361
  # mark or digit in any of them: "Nobody used it. A named owner. Then a
@@ -3055,8 +3492,9 @@ module Sloplint
3055
3492
  ),
3056
3493
  Rule.new(
3057
3494
  id: "em-dash",
3058
- category: "structure",
3495
+ category: "punctuation",
3059
3496
  severity: "info",
3497
+ confidence: "medium",
3060
3498
  pattern: /—/,
3061
3499
  message: "Em dash — an AI punctuation tell.",
3062
3500
  suggestion: "Recast with a comma, parentheses, or a separate sentence.",
@@ -3075,8 +3513,9 @@ module Sloplint
3075
3513
  ),
3076
3514
  Rule.new(
3077
3515
  id: "em-dash-overuse",
3078
- category: "structure",
3516
+ category: "punctuation",
3079
3517
  severity: "warning",
3518
+ confidence: "high",
3080
3519
  pattern: /—(?:[^\n]|\n(?!\s*\n))*—(?:[^\n]|\n(?!\s*\n))*—/,
3081
3520
  message: "Three or more em dashes in one paragraph — an AI punctuation tell.",
3082
3521
  suggestion: "Recast with commas, parentheses, or separate sentences.",
@@ -3098,8 +3537,9 @@ module Sloplint
3098
3537
 
3099
3538
  Rule.new(
3100
3539
  id: "question-isnt",
3101
- category: "structure",
3102
- severity: "info",
3540
+ category: "false-correction",
3541
+ severity: "warning",
3542
+ confidence: "medium",
3103
3543
  # The resolving clause is required, so a plain rhetorical question never
3104
3544
  # matches. "The real question is" is ordinary English and is excluded by
3105
3545
  # the adjacency of "question" to the negated copula.
@@ -3125,8 +3565,9 @@ module Sloplint
3125
3565
  ),
3126
3566
  Rule.new(
3127
3567
  id: "less-about-more-about",
3128
- category: "structure",
3129
- severity: "info",
3568
+ category: "false-correction",
3569
+ severity: "warning",
3570
+ confidence: "medium",
3130
3571
  # The full frame is required at both ends. Bare "less about" and bare
3131
3572
  # "more about" are ordinary English on their own, and the subject slot
3132
3573
  # is limited to the pronouns so that a sentence with a real subject --
@@ -3152,8 +3593,9 @@ module Sloplint
3152
3593
  ),
3153
3594
  Rule.new(
3154
3595
  id: "trailing-significance-participle",
3155
- category: "structure",
3156
- severity: "warning",
3596
+ category: "puffery",
3597
+ severity: "error",
3598
+ confidence: "high",
3157
3599
  # The verb list is closed and short on purpose. Wikipedia's "signs of AI
3158
3600
  # writing" names eight watch words for this construction; half of them
3159
3601
  # did not survive probing. In 1.85M words of pre-2022 Hacker News and
@@ -3228,11 +3670,103 @@ module Sloplint
3228
3670
  "narrator who never appears. Careful writers put the verb in its own clause " \
3229
3671
  "with a subject, or leave the significance to the reader."
3230
3672
  ),
3231
- # ── hedging ───────────────────────────────────────────────────────────
3232
3673
  Rule.new(
3233
- id: "vague-attribution",
3234
- category: "hedging",
3674
+ id: "trailing-restatement",
3675
+ category: "closer",
3235
3676
  severity: "warning",
3677
+ confidence: "low",
3678
+ # The restating tail: "…, which means working through the process
3679
+ # rather than around it". A regex sees the connective and not whether
3680
+ # the tail says the head again, and the sentence that prompted the rule
3681
+ # shares no content words between the two, so no overlap test reaches
3682
+ # it either. Hence off by default.
3683
+ #
3684
+ # Three connectives, and four participle frames that make the same
3685
+ # move. Nothing before the comma is inspected: a gloss ("_ma_, which
3686
+ # means hand") and a real tell after a code span or a bold phrase end
3687
+ # on the same characters, so a guard there costs more hits than it
3688
+ # saves. Glosses are a known cost of the rule. The participle
3689
+ # "meaning" must open on one of a closed set of determiners and
3690
+ # pronouns, which keeps out the noun ("meaning of"), the intention
3691
+ # ("meaning to come back") and the bare-noun gloss ("meaning six"); a
3692
+ # gloss that takes an article ("meaning the red stick") gets through.
3693
+ # Each participle frame needs a pronoun object and a closing word,
3694
+ # because the bare participle is ordinary English ("she left, leaving
3695
+ # the door open"). The closer on "making" is a comparative or
3696
+ # (im)possible and must end the clause or lead into "to", "for" or
3697
+ # "than", since "-er" alone is also "wonder", "offer" and "her". These
3698
+ # verbs stay off trailing-significance-participle, whose list is closed
3699
+ # to verbs an event can be the subject of.
3700
+ pattern: /,#{WRAP_GAP}+
3701
+ (?:which#{WRAP_GAP}+means\b
3702
+ |which#{WRAP_GAP}+is#{WRAP_GAP}+to#{WRAP_GAP}+say\b
3703
+ |meaning#{WRAP_GAP}+(?:that|the|a|an|this|these|those|it|he|she|we|you|they|there|nothing|every|each|any|all|most|some|your|our|their|its)\b
3704
+ |making#{WRAP_GAP}+(?:it|them|us|you|the(?:#{WRAP_GAP}+[\w'’-]+){1,2})#{WRAP_GAP}+
3705
+ (?:[a-z]+er|(?:im)?possible|(?:more|less)(?:#{WRAP_GAP}+[a-z]+)?)
3706
+ (?=#{WRAP_GAP}+(?:to|for|than)\b|[ \t]*[.,;:!?)]|[ \t]*\r?\n|[ \t]*\z)
3707
+ |allowing#{WRAP_GAP}+(?:it|them|us|you)#{WRAP_GAP}+to\b
3708
+ |giving#{WRAP_GAP}+(?:them|us|you)#{WRAP_GAP}+(?:more|less|time|room)\b
3709
+ |leaving#{WRAP_GAP}+(?:them|us|you)#{WRAP_GAP}+with(?:out)?\b)/ix,
3710
+ message: "Trailing clause that says the sentence again, or hangs a result off it.",
3711
+ suggestion: "Cut the tail, or if it states a real consequence, make it its own sentence.",
3712
+ examples_bad: [
3713
+ "We moved the checks into the build step, which means the errors show up before anyone opens a review.",
3714
+ "The new queue drains in order, which is to say nothing jumps ahead of an older job.",
3715
+ "Every job now records its own start time, meaning the log tells you when the run began.",
3716
+ "He signed at once, meaning he had read it already.",
3717
+ "We rebuilt the parser, making the whole pipeline faster.",
3718
+ "The cache is local now, making it easier to reason about.",
3719
+ "The change is small, making it more robust.",
3720
+ "The lock is per row, making it impossible for two writers to collide.",
3721
+ "The cache now lives beside the worker, allowing us to skip the round trip.",
3722
+ "The report ships on Fridays, giving them more time to read it.",
3723
+ "The old flags are gone, leaving you with one switch to learn.",
3724
+ # Nothing before the comma is inspected.
3725
+ "The flag defaults to `false`, which means nothing is written to disk.",
3726
+ "- **Cache is local**, which means the round trip is gone.",
3727
+ "We fixed the bug (the null check), which means the crash is gone.",
3728
+ # Either gap may hard-wrap.
3729
+ "We moved the checks into the build step,\nwhich means the errors show up first.",
3730
+ "We moved the checks into the build step, which\nmeans the errors show up first."
3731
+ ],
3732
+ examples_ok: [
3733
+ # "meaning" outside the closed set: the bare-noun gloss, the intention, the noun.
3734
+ "They count on with tatisitupe, meaning six.",
3735
+ "It is an old word, meaning caves.",
3736
+ "He left the room, meaning to come back before dark.",
3737
+ "She read on, meaning of the word aside, and let it pass.",
3738
+ "He shrugged, meaning no harm by it.",
3739
+ # The bare participles are ordinary English.
3740
+ "She left the room, leaving the door open.",
3741
+ "Cut the paper around the frame, leaving a margin for pasting.",
3742
+ "He handed over the keys, giving her a nod.",
3743
+ "The lid lifts off, allowing the steam to escape.",
3744
+ # A pronoun object with no closing word, or the wrong one.
3745
+ "Rub down the leather, making it as smooth as possible.",
3746
+ "The crowd parted, making room for more chairs.",
3747
+ "He pulled the cork, giving it time to breathe.",
3748
+ "He shut the door, leaving them to it.",
3749
+ "The porter took the trunk, leaving it with the station master.",
3750
+ # "-er" that is not a comparative, and a comparative that does not close the clause.
3751
+ "The rain kept up all week, making you wonder whether the trip was worth it.",
3752
+ "She turned the coat inside out, making it her own.",
3753
+ "They argued, making the same point over and over.",
3754
+ "The tide turned, making it matter less than before.",
3755
+ # A gerund list.
3756
+ "The work involves cutting, making and sanding the parts.",
3757
+ # A paragraph break is not a comma.
3758
+ "The checks moved into the build step\n\nWhich means the errors show up first."
3759
+ ],
3760
+ rationale: "The tail after the connective says the head again in other words, and a " \
3761
+ "model adds one whenever a sentence feels short of a point. Careful writers " \
3762
+ "use the same connective to state a consequence, and the pattern cannot " \
3763
+ "tell the two apart, so the rule is off by default."
3764
+ ),
3765
+ Rule.new(
3766
+ id: "vague-attribution",
3767
+ category: "false-concession",
3768
+ severity: "error",
3769
+ confidence: "high",
3236
3770
  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,
3237
3771
  message: "Vague attribution ('some critics argue', 'it is widely…') — an AI hedging tell.",
3238
3772
  suggestion: "Name the source, or drop the appeal to unnamed authority.",