sloplint 0.5.0 → 0.7.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.
@@ -16,6 +16,72 @@ module Sloplint
16
16
  end
17
17
  end
18
18
 
19
+ # A paragraph break: a newline, a line holding nothing but spaces (a
20
+ # non-breaking space among them, since the editors that emit curly
21
+ # apostrophes emit those), and another newline.
22
+ PARA_BREAK = /\r?\n[ \t\u00A0]*\r?\n/
23
+
24
+ # A gap that may hard-wrap but never crosses a paragraph break. A
25
+ # non-breaking space is a gap too. thats-the-whole, is-the-whole-x and
26
+ # WHOLE_CLOSERS share it.
27
+ WRAP_GAP = /(?:[ \t\u00A0]|(?!#{PARA_BREAK})\r?\n)/
28
+
29
+ # The nouns "that's the whole N" closes on. thats-the-whole owns the
30
+ # demonstrative form and is-the-whole-x yields it, so both patterns
31
+ # interpolate this one fragment and the lists cannot drift. "value" and
32
+ # "fix" must end the sentence or the paragraph, or run into one of the
33
+ # words a closer trails off on (a preposition, a pronoun, a determiner,
34
+ # "right", "though", "now"), because "value chain", "value-add" and
35
+ # "fix list" name things. The older nouns compound too ("game plan") and
36
+ # ship as
37
+ # they always have; only the two new ones were probed for it. The list
38
+ # of tails is an allowlist and stays one: a blocklist of compound heads
39
+ # would widen every time a new compound turned up. A hyphen or an
40
+ # apostrophe ends the closer only after a gap, since with no gap it is
41
+ # part of the compound; an opening delimiter after a gap (an emphasis
42
+ # marker, a backtick, a quote, a bracket) is not an end either, since it
43
+ # opens the next word ("value *chain*", "fix `list`"). Under --markdown
44
+ # a code span is blanked to spaces and the tail after it reads as the
45
+ # closer's; that is the blanking's cost, not this one's. A numbered list
46
+ # item on the next line ends the closer like a bullet does. Prepositions
47
+ # stay tails although "value at risk" and "value for money" name things;
48
+ # a tail list is not the place to enumerate compounds. The
49
+ # gap may hard-wrap but never crosses a paragraph break, so
50
+ # "value\nchain" is still the compound, and a heading, which ends at a
51
+ # blank line, still ends on the noun. The character classes are
52
+ # Unicode-aware, so a non-breaking space is a space and an accented
53
+ # letter is a letter.
54
+ 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
+
56
+ # Nouns that only ever name the writer's own construction -- never a
57
+ # concrete object, a person, or an idiom -- and that all four of
58
+ # clean-x, cleanest-x, honest-x and most-honest-x already matched before
59
+ # this constant existed. "comparison" and "through-line" read the same way
60
+ # but are each missing from one rule's original list (clean-x never had
61
+ # "comparison", cleanest-x never had "through-line"), so both stay out of
62
+ # this constant and inline in the three rules that carry them -- putting
63
+ # either one here would add a match the fourth rule never had.
64
+ WRITERS_OWN_CONSTRUCTION_NOUNS = "framing|formulation|mapping|abstraction"
65
+
66
+ # A sentence of at least sixty characters, ending in sentence-ending
67
+ # punctuation followed by one or two spaces -- the setup mic-drop-closer's
68
+ # kicker needs before it looks for the closer. The atomic group (?>...) is
69
+ # load-bearing against catastrophic backtracking; see the comment on
70
+ # mic-drop-closer's pattern for the incident that made it necessary.
71
+ SENTENCE_OF_SIXTY_CHARACTERS_ENDING_IN_PUNCTUATION_AND_SPACE =
72
+ /(?:^|(?<=[.!?])[ \t]{1,2})(?>(?:[^.!?\n\s]|(?<![ \t])[ \t]{1,2}(?![ \t])|\r?\n(?!\s*\n)[ \t]*){60,})[.!?][ \t]{1,2}/
73
+
74
+ # real-x-real-y interpolates this fragment after each of its two "real"s, so
75
+ # a later narrowing can only ever apply to both at once -- two copies typed
76
+ # out separately could drift, one gaining an exception the other never
77
+ # sees. Function words that only ever continue a predicative "is real",
78
+ # then the fixed senses common in this register that are never the
79
+ # doubled-intensifier tell: real time, real-world, the math sense (real
80
+ # numbers/roots), real user (monitoring), real estate, real money.
81
+ REAL_X_REAL_Y_EXCLUDED_NEXT_WORD =
82
+ "and|but|or|nor|yet|too|enough|itself|indeed|which|that|here|there|so" \
83
+ "|time|world|numbers?|roots?|money|estate|user"
84
+
19
85
  RULES = [
20
86
  # ── rhetorical-tic ────────────────────────────────────────────────────
21
87
  Rule.new(
@@ -83,13 +149,313 @@ module Sloplint
83
149
  id: "thats-the-whole",
84
150
  category: "rhetorical-tic",
85
151
  severity: "warning",
86
- pattern: /\b(?:that|this)(?:'s| is)\s+the\s+whole\s+(?:point|game|thing|deal|story|ballgame|ball\s+game)\b/i,
152
+ # The nouns are WHOLE_CLOSERS. "value" and "fix" are the closers agents
153
+ # write in technical prose ("That's the whole fix"), where the
154
+ # contraction keeps them out of is-the-whole-x, which sees only "is".
155
+ # Either apostrophe counts; editors emit the curly one.
156
+ pattern: /\b(?:that|this)(?:['’]s|#{WRAP_GAP}+is)#{WRAP_GAP}+the#{WRAP_GAP}+whole#{WRAP_GAP}+(?:#{WHOLE_CLOSERS})\b/i,
87
157
  message: '"That\'s the whole point/game/…" is a stock LLM closer.',
88
158
  suggestion: "Say the point directly instead of announcing it.",
89
- examples_bad: ["That's the whole point."],
90
- examples_ok: ["This is the whole cake."],
159
+ examples_bad: [
160
+ "That's the whole point.",
161
+ "That's the whole value of a typed error.",
162
+ "That's the whole fix; the cache was already right.",
163
+ "That’s the whole value here.",
164
+ "That's the whole fix right there.",
165
+ "That's the whole fix that was needed.",
166
+ "That's the whole fix the reviewer asked for.",
167
+ # A blank line holding only a non-breaking space is still blank.
168
+ "## That's the whole fix\n\u00A0\nApply it.",
169
+ # A dash or a bullet after a gap ends the closer; only a glued
170
+ # hyphen joins a compound.
171
+ "That's the whole fix -- the cache was already right.",
172
+ "- Cache key was stale.\n- That's the whole fix\n- Tests pass.",
173
+ "2. That's the whole fix\n3. Tests pass.",
174
+ # A heading ends on the noun with no full stop.
175
+ "## That's the whole fix\n\nApply it and rerun the suite."
176
+ ],
177
+ examples_ok: [
178
+ "This is the whole cake.",
179
+ # An unlisted noun stays clean, however closer-shaped the sentence.
180
+ "That's the whole history of the case.",
181
+ # "value" and "fix" running on into a compound name a thing.
182
+ "That's the whole value chain, end to end.",
183
+ "That's the whole value\nchain, end to end.",
184
+ # The gap is a non-breaking space.
185
+ "That's the whole value\u00A0chain, end to end.",
186
+ "That's the whole value *chain*, end to end.",
187
+ "That's the whole fix `list` for the release.",
188
+ "That's the whole fix (list) for the release.",
189
+ "That's the whole value-add of the consultant.",
190
+ "That's the whole value's worth.",
191
+ "That's the whole fix list for the release.",
192
+ # A paragraph break is not a gap, inside the two-word noun or
193
+ # before any noun.
194
+ "That's the whole ball\n\ngame.",
195
+ "That's the whole\n\npoint."
196
+ ],
91
197
  rationale: "The 'that's the whole X' flourish is a model tic for landing a paragraph."
92
198
  ),
199
+ Rule.new(
200
+ id: "is-the-whole-x",
201
+ category: "rhetorical-tic",
202
+ severity: "info",
203
+ # The generic form of thats-the-whole: a subject, then "is the whole /
204
+ # real / actual / entire N" with N from a closed abstract list. "That
205
+ # periodicity is the whole tell.", "Consistency is the real test." The
206
+ # match opens on the subject's last word, so the note points at the
207
+ # sentence and not at a space. Two older rules own two exact shapes,
208
+ # and those are yielded so nothing is reported twice: "that/this is
209
+ # the whole N" for every N in WHOLE_CLOSERS to thats-the-whole (the
210
+ # lookahead interpolates the same fragment), and "is the entire
211
+ # point/game/thing/deal/story" to is-the-entire; every
212
+ # other subject and noun flags here, so "This is the real test." is
213
+ # not lost. A question is not a closer, so an interrogative subject
214
+ # (what, which, who, where, when, how) is out. "only" is left out
215
+ # because "is the only thing" is ordinary speech; "deal", "thing" and
216
+ # "cost" because "the real deal", "the real thing" and "the whole cost"
217
+ # are idioms or quantities. The noun may not run on into a compound
218
+ # ("problem-solver"). Gaps may cross a hard-wrapped newline but never
219
+ # a paragraph break. Ships at info because "the real question" and
220
+ # "the whole point" are also how people talk.
221
+ pattern: /(?<![\w'’-])
222
+ (?!(?:that|this)#{WRAP_GAP}+(?:is)#{WRAP_GAP}+(?:the)#{WRAP_GAP}+(?:whole)#{WRAP_GAP}+(?:#{WHOLE_CLOSERS})(?![\w'’-]))
223
+ (?!(?:what|which|who|where|when|how)(?![\w'’-]))
224
+ [\w'’-]+#{WRAP_GAP}+(?:is)#{WRAP_GAP}+(?:the)#{WRAP_GAP}+
225
+ (?:(?:whole|real|actual)#{WRAP_GAP}+(?:tell|point|game|story|trick|question|problem|issue|lesson|job|work|move|test|signal|difference|answer|risk|goal|reason|pattern|insight|takeaway|shift|bet|win|catch|gap|bottleneck|value|skill|challenge|fix)
226
+ |entire #{WRAP_GAP}+(?!(?:point|game|thing|deal|story)(?![\w'’-]))(?:tell|point|game|story|trick|question|problem|issue|lesson|job|work|move|test|signal|difference|answer|risk|goal|reason|pattern|insight|takeaway|shift|bet|win|catch|gap|bottleneck|value|skill|challenge|fix))(?![\w'’-])/ix,
227
+ message: '"… is the whole/real N" is a stock LLM closer.',
228
+ suggestion: "Say the point directly instead of ranking it.",
229
+ examples_bad: [
230
+ "That periodicity is the whole tell.",
231
+ "Consistency is the real test.",
232
+ "Getting the handoff right is the actual work.",
233
+ # Not thats-the-whole's nouns, so not yielded.
234
+ "This is the real test.",
235
+ "That is the actual problem.",
236
+ # "entire" with a noun is-the-entire does not own.
237
+ "Timing is the entire tell.",
238
+ # A hard-wrapped closer survives one newline.
239
+ "Consistency\nis the real test."
240
+ ],
241
+ examples_ok: [
242
+ # Left to thats-the-whole, an old noun and a widened one.
243
+ "That is the whole point.",
244
+ "That is the whole fix.",
245
+ # Left to is-the-entire.
246
+ "Timing is the entire game.",
247
+ # "only" is ordinary speech.
248
+ "Sleep is the only thing that helps.",
249
+ "The real question was never asked.",
250
+ # Idioms and quantities.
251
+ "Clojure is the real deal, and so is the REPL.",
252
+ "The 1962 recording is the real thing.",
253
+ "The remaining balance is the whole cost of the repair.",
254
+ # A compound with a listed noun.
255
+ "She is the real problem-solver on the team.",
256
+ # A question is not a closer.
257
+ "What is the real difference between the two plans?",
258
+ "Nobody knows what is the actual cost of storage.",
259
+ # A paragraph break is not a gap.
260
+ "Consistency\n\nis the real test."
261
+ ],
262
+ rationale: "Ranking a claim as 'the whole point' or 'the real test' is how a model " \
263
+ "lands a paragraph without adding to it. People say it too, so one is a " \
264
+ "question; several in a draft should be read as a warning."
265
+ ),
266
+ Rule.new(
267
+ id: "bare-equative",
268
+ category: "rhetorical-tic",
269
+ severity: "info",
270
+ # A sentence that opens on an abstract head noun and equates it with
271
+ # something: "The tell here is the periodicity.", "The problem is not
272
+ # the tool.", "The lesson is the handoff." The head noun list is closed
273
+ # and holds only nouns that cannot name an object, the same list
274
+ # the-x-is-the-x uses: "the key is the brass thing on the hook" and
275
+ # "the cost is the price of the ticket" are definitions that tell the
276
+ # reader something. The sentence must start on "The", at a sentence
277
+ # start or after a list marker, and the copula ("is", "is not",
278
+ # "isn't") must be followed by "the" and a lowercase word, so a
279
+ # predicate adjective ("The problem is real"), an indefinite ("The
280
+ # answer is a mess"), a pointing complement ("the same", "the one",
281
+ # "the first"), and a proper noun ("the Slack thread") are all out.
282
+ # 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
285
+ # tells.
286
+ pattern: /(?:^|(?<=[.!?])[ \t]{1,2})(?:[-*+•][ \t]+|\d+[.)][ \t]+)?\KThe(?:[ \t]|\r?\n(?!\s*\n))+
287
+ (?: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))+
288
+ (?:here(?:[ \t]|\r?\n(?!\s*\n))+)?is(?:n['’]t|(?:[ \t]|\r?\n(?!\s*\n))+not)?(?:[ \t]|\r?\n(?!\s*\n))+ the(?:[ \t]|\r?\n(?!\s*\n))+(?!(?:one|same|only|first|last|best|worst|next|other|latter|former)\b)(?-i:(?=[a-z]))/x,
289
+ message: '"The X is the Y." is the AI definitional equative.',
290
+ suggestion: "Say what the thing does or why it matters, instead of what it equals.",
291
+ examples_bad: [
292
+ "The tell here is the periodicity.",
293
+ "The lesson is the handoff, not the tool.",
294
+ "The problem is not the tool. It is the habit.",
295
+ "The problem isn't the handoff.",
296
+ # The pair's usual habitat.
297
+ "- The point is the cadence.",
298
+ # A hard-wrapped equative survives one newline.
299
+ "The problem is the\ncost."
300
+ ],
301
+ examples_ok: [
302
+ "The problem is real.",
303
+ "The answer is a mess of caveats.",
304
+ # Not at a sentence start.
305
+ "We think the problem is the handoff.",
306
+ # Concrete heads are definitions.
307
+ "The key is the brass thing on the hook.",
308
+ "The cost is the price of the ticket plus tax.",
309
+ # Pointing complements.
310
+ "The story is the one she told last week.",
311
+ "The reason is the same: space.",
312
+ "The point is the first item on the list.",
313
+ # A proper noun.
314
+ "The tell is the Slack thread.",
315
+ # A paragraph break is not a gap.
316
+ "The problem is the\n\ncost."
317
+ ],
318
+ rationale: "Opening on an abstract noun and equating it with a second noun phrase " \
319
+ "states a diagnosis as a definition, which sounds settled and explains " \
320
+ "nothing. People write the shape too, so one is a question; several in " \
321
+ "a draft should be read as a warning."
322
+ ),
323
+ Rule.new(
324
+ id: "epistrophe",
325
+ category: "rhetorical-tic",
326
+ severity: "info",
327
+ default_on: false,
328
+ # Two clauses that end on the same two-word phrase, the second closing
329
+ # the sentence: "built for one desk, and almost no job is done at one
330
+ # desk." Two backreferences catch the repeat, one per word, so the
331
+ # phrase may be hard-wrapped at either occurrence. The phrase may not
332
+ # open on an article, since "in the report, and … in the report" is
333
+ # ordinary; its second word must be four letters or more; and the
334
+ # second clause is five to sixty characters with no internal
335
+ # punctuation, opening on a word, with whitespace runs capped at two
336
+ # spaces (or a hard wrap with a small indent), so a code span or URL
337
+ # blanked by --markdown cannot weld a false repeat. Off by default:
338
+ # this is a named figure that Emerson and Marcus Aurelius use on
339
+ # purpose, and on Hacker News most hits are ordinary phrase reuse
340
+ # ("what we said, not what the minutes say we said"), so the rate is
341
+ # above what an info rule should carry. Select it when a draft is
342
+ # suspected of leaning on it.
343
+ pattern: /\b((?!the\b|a\b|an\b)[\w'’-]+)(?:[ \t]|\r?\n(?!\s*\n)[ \t]{0,4})+([\w'’-]{4,}),(?:[ \t]{1,2}|\r?\n(?!\s*\n)[ \t]{0,4})(?:(?:and|but)(?:[ \t]|\r?\n(?!\s*\n)[ \t]{0,4})+)?
344
+ (?=\w)(?:[^.,;:!?\n\s]|(?<![ \t])[ \t]{1,2}(?![ \t])|\r?\n(?!\s*\n)[ \t]{0,4}){5,60}?\b\1(?:[ \t]|\r?\n(?!\s*\n)[ \t]{0,4})+\2[.!?]/ix,
345
+ message: "Two clauses ending on the same phrase (epistrophe) read as AI cadence.",
346
+ suggestion: "Vary the second ending, or cut the repeat.",
347
+ examples_bad: [
348
+ "The tool was built for one desk, and almost no job is done at one desk.",
349
+ "They wanted a shared file, but nobody would maintain a shared file.",
350
+ # A hard-wrapped repeat survives one newline, in either clause.
351
+ "The tool was built for one desk, and almost no job\nis done at one desk.",
352
+ "The tool was built for one\ndesk, and almost no job is done at one desk."
353
+ ],
354
+ examples_ok: [
355
+ "The tool was built for one desk, and almost no job is done alone.",
356
+ # A repeat across a sentence boundary is two sentences.
357
+ "They wanted a shared file. Nobody would maintain a shared file.",
358
+ # An article-led phrase is ordinary repetition.
359
+ "It was in the report, and the numbers were in the report.",
360
+ # The second word must be four letters or more.
361
+ "I like the blue one, and she likes the blue one.",
362
+ # The second clause is at most sixty characters.
363
+ "The tool was built for one desk, and almost no job anywhere in the whole company across all of its many offices is done at one desk.",
364
+ # No internal punctuation in the second clause.
365
+ "The tool was built for one desk, and, as it happens, no job is done at one desk.",
366
+ # Blanked text cannot make the second clause.
367
+ "It was tuned for one desk, and on one desk."
368
+ ],
369
+ rationale: "Ending consecutive clauses on the same phrase is a figure of emphasis, " \
370
+ "and a model reaches for it whenever it wants a sentence to land. People " \
371
+ "use it too, and much of what the pattern catches is plain phrase reuse, " \
372
+ "which is why the rule is off by default; when selected, several in a " \
373
+ "draft should be read as a warning."
374
+ ),
375
+ Rule.new(
376
+ id: "phrase-echo",
377
+ category: "rhetorical-tic",
378
+ severity: "info",
379
+ default_on: false,
380
+ # The same three words again a few paragraphs on. Three consecutive
381
+ # words, each four characters or more and lowercase-led, one of them
382
+ # six letters with nothing but letters, and the same three again
383
+ # within about 400 words. That is the whole defence, and it is
384
+ # structural: length drops the function-word runs, the all-letters
385
+ # word drops the contractions and hyphenated compounds that would
386
+ # otherwise pass on characters alone, case drops the names and
387
+ # headings. The gaps inside the phrase are capped at two spaces, as
388
+ # in epistrophe, so a code span blanked by --markdown cannot weld two
389
+ # words into a phrase.
390
+ #
391
+ # The window is a lazy walk of word steps in an atomic group, so
392
+ # nothing backtracks, and it counts Unicode words, since `\w` is
393
+ # ASCII in Ruby. Each gap in the walk is capped at 80 non-word
394
+ # characters and refuses to cross into a list item or a table row:
395
+ # a blanked code fence, a rule of dashes or a bullet is a wall, not a
396
+ # step. The repeat sits in a lookahead so the match, and the excerpt,
397
+ # is the first occurrence alone rather than the whole span; the
398
+ # suggestion says so. The repeat may not open on a quote mark, a
399
+ # backtick, an emphasis marker, a hyphen or a table bar: a quoted
400
+ # self-repeat is deliberate, and "re-shared" is not "shared". One
401
+ # backreference per word so either occurrence may be hard-wrapped.
402
+ #
403
+ # Off by default: in reference prose the rate runs to thousands per
404
+ # million words, all of it terms of art and running epithets.
405
+ pattern: /(?<![\p{Word}'’-])
406
+ (?=(?:[\w'’-]+(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})){0,2}[a-z]{6,}(?![\w'’-]))
407
+ ([a-z][\w'’-]{3,})(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
408
+ ([a-z][\w'’-]{3,})(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
409
+ ([a-z][\w'’-]{3,})\b
410
+ (?=(?>(?:(?!\r?\n[ \t]*(?:[-*+•|]|\d+[.)])[ \t])\P{Word}){1,80}\p{Word}+){0,400}?
411
+ (?:(?!\r?\n[ \t]*(?:[-*+•|]|\d+[.)])[ \t])[^\p{Word}"“‘'`*|-]){1,80}
412
+ \1(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
413
+ \2(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
414
+ \3\b)/x,
415
+ message: "Three-word phrase that comes back within a few hundred words -- a model reusing its own output.",
416
+ 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.",
417
+ examples_bad: [
418
+ "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.",
419
+ "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.",
420
+ # A hard-wrapped first occurrence.
421
+ "We keep a shared review\nchecklist in the repo. Everyone edits the shared review checklist first.",
422
+ # The last word inside the window.
423
+ "We keep a shared review checklist. #{"word " * 399}The shared review checklist is short."
424
+ ],
425
+ examples_ok: [
426
+ # Function words fall out on length.
427
+ "In order to ship we cut scope, and in order to ship again we cut it more.",
428
+ # Four-letter words alone are not enough; one word must be six letters, letters only.
429
+ "It would have been better, and it would have been faster.",
430
+ "It couldn't have been worse, and it couldn't have been better.",
431
+ "We saw every top-ten list here, and every top-ten list there.",
432
+ # Proper nouns and Title Case headings fall out on case.
433
+ "Grand Central Station has one, and Grand Central Station wants two.",
434
+ "Incident Response Plan\n\nThe Incident Response Plan covers the first hour.",
435
+ # The repeat is beyond the window, in words, in non-ASCII words, or past a wall of dashes.
436
+ "We keep a shared review checklist. #{"word " * 400}The shared review checklist is short.",
437
+ "We keep a shared review checklist. #{"слово " * 400}The shared review checklist is short.",
438
+ "We keep a shared review checklist.\n\n#{"-" * 100}\n\nThe shared review checklist is short.",
439
+ # A quoted repeat is deliberate, whatever the quote mark.
440
+ "The shared review checklist is new. He wrote \"shared review checklist\" on the board.",
441
+ "The shared review checklist is new. He wrote 'shared review checklist' on the board.",
442
+ "The shared review checklist is new. He wrote `shared review checklist` on the board.",
443
+ # A different word form is a different phrase, and so is a hyphenated compound.
444
+ "The shared review checklist grew, and then both shared review checklists grew.",
445
+ "The auto-generated review checklist was long. The hand-generated review checklist was longer.",
446
+ "We use a shared review checklist daily. Nobody re-shared review checklist edits.",
447
+ # A run of spaces where --markdown blanked a code span does not weld a phrase.
448
+ "We keep a shared review checklist here. Everyone edits the shared review checklist first.",
449
+ # List items and table rows are furniture, not prose.
450
+ "- shared review checklist covers pull requests\n- shared review checklist covers deploys",
451
+ "1. Update the shared config file.\n2. Restart the shared config file watcher.",
452
+ "| Task | Status |\n|---|---|\n| A | needs manual review |\n| B | needs manual review |"
453
+ ],
454
+ rationale: "A model reuses a phrase it has just minted because its own recent output is " \
455
+ "the likeliest continuation, so the same three words turn up again a few " \
456
+ "paragraphs on, doing no new work. Terms of art repeat too, and the pattern " \
457
+ "cannot tell a coined phrase from a name, so the rule is off by default."
458
+ ),
93
459
  Rule.new(
94
460
  id: "did-not-x-did-not-y",
95
461
  category: "rhetorical-tic",
@@ -102,6 +468,385 @@ module Sloplint
102
468
  examples_ok: ["He didn't know the answer."],
103
469
  rationale: "Repeated negated-verb parallelism is a signature model cadence."
104
470
  ),
471
+ Rule.new(
472
+ id: "from-x-to-y-chain",
473
+ category: "rhetorical-tic",
474
+ severity: "warning",
475
+ # Two or more "from X to Y" spans in a row, comma-separated: "from
476
+ # private notes to shared files, from personal memory to team context".
477
+ # Each span is "from", one to three words, "to", one to three words; the
478
+ # comma between spans is the evidence of authored parallelism, as in
479
+ # no-x-no-y, so a chain never crosses a sentence boundary. Every operand
480
+ # must open with a letter, so a list of ranges ("from 1990 to 1995, from
481
+ # 1997 to 2001", "from 9 to 5") is not a chain. The last span is bounded
482
+ # too: its Y must run into punctuation, a conjunction or preposition, a
483
+ # wh-word, or the end of the text, so the excerpt never carries the
484
+ # opening of the next clause.
485
+ #
486
+ # Two human shapes share the surface and are skipped. The relay, where
487
+ # each span starts where the last one ended ("from the egg to the worm,
488
+ # from the worm to the fly"), traces a sequence: the second "from"
489
+ # repeating the first "to" drops the note. The reduplication, "from
490
+ # hummock to hummock, from root to root", describes motion: a span whose
491
+ # two ends are the same words drops it. Both backreferences must close
492
+ # on a whole operand, so "work-life" is not a relay of "work" and "code
493
+ # review" is not a reduplication of "code".
494
+ #
495
+ # A list of concrete mappings ("from MySQL to Postgres, from Redis to
496
+ # Memcached") has the same surface and flags. That is an accepted cost:
497
+ # the shape is one hit in 2.4M words of Hacker News, and the note says
498
+ # what to check.
499
+ pattern: /\bfrom\s+(?=[a-z])(?:[\w'-]+\s+){1,3}to\s+(?=[a-z])(?:[\w'-]+\s+){0,2}[\w'-]+
500
+ (?:,\s+(?:and\s+)?from\s+(?=[a-z])(?:[\w'-]+\s+){1,3}to\s+(?=[a-z])(?:[\w'-]+\s+){0,2}[\w'-]+)+
501
+ (?=[^\w\s'-]|\s+(?:and|or|but|in|on|at|by|with|within|over|across|as|when|while|which|that|so|because|what|how|why|who)\b|\s*\z)/ix,
502
+ message: '"from X to Y, from X to Y" chain (%{count} spans) reads as AI cadence.',
503
+ suggestion: "Keep one span, or name the things instead of sweeping across them.",
504
+ # Span heads only, so a "from" inside an operand is not a span.
505
+ count_group: /(?:\A|,\s+(?:and\s+)?)from\b/i,
506
+ skip: [
507
+ # The relay: "to the worm, from the worm".
508
+ /\bto\s+(?:[\w'-]+\s+){0,2}([\w'-]+),\s+(?:and\s+)?from\s+(?:[\w'-]+\s+){0,2}\1(?![\w'-])/i,
509
+ # The reduplication: "from hummock to hummock".
510
+ /\bfrom\s+(?:the\s+)?((?:[\w'-]+\s+){0,2}[\w'-]+)\s+to\s+(?:the\s+)?\1(?=,|\s*\z)/i
511
+ ],
512
+ examples_bad: [
513
+ "The change is the move from scattered notes to one shared file, from habit to written rules, from solo effort to a team that can carry it.",
514
+ "We went from guessing to measuring, from hoping to knowing.",
515
+ "The plan takes them from the pilot to the rollout, and from the memo to the audit.",
516
+ # The last span may run into a clause, as long as a joining word starts it.
517
+ "We went from guessing to measuring, from hoping to knowing in a single quarter.",
518
+ # Not a relay: "work-life" is not "work".
519
+ "We went from rest to work, from work-life balance to burnout.",
520
+ # Not a reduplication: "code review" is not "code".
521
+ "We moved from code to code review, from guessing to measuring.",
522
+ # A "from" inside an operand is not a span head.
523
+ "The shift from revenue from ads to revenue from subscriptions, from hoping to knowing."
524
+ ],
525
+ examples_ok: [
526
+ "The train runs from Boston to New York.",
527
+ # Pride and Prejudice (Austen, public domain): the relay.
528
+ "it jumps from admiration to love, from love to matrimony, in a moment.",
529
+ # Walden (Thoreau, public domain): the reduplication.
530
+ "jumping from hummock to hummock, from willow root to willow root, when the wild river valley",
531
+ # A span longer than three words on either side is a clause, not an item.
532
+ "He drove from the coast to the mountains in a day, and from there the road was easy.",
533
+ # Two spans in separate sentences never chain.
534
+ "She moved from Ohio to Maine. From there she wrote to him weekly.",
535
+ # Ranges are not spans.
536
+ "He served on the board from 1990 to 1995, from 1997 to 2001, and from 2005 to 2009.",
537
+ "We are open from 9 to 5, from Monday to Friday."
538
+ ],
539
+ rationale: "Stacked 'from X to Y' spans are a model's way of gesturing at a whole " \
540
+ "transformation without describing any of it; each span names two poles and " \
541
+ "nothing between them. Human prose stacks the phrase for a sequence (each span " \
542
+ "picking up where the last ended) or for motion (the same word at both ends), " \
543
+ "and both of those are skipped. What remains is rare in careful writing; a list " \
544
+ "of concrete mappings (tools migrated, units converted) shares the shape and is " \
545
+ "the case to check before acting."
546
+ ),
547
+ Rule.new(
548
+ id: "one-x-one-y",
549
+ category: "rhetorical-tic",
550
+ severity: "warning",
551
+ # Three or more "one X" items in a comma chain that stands on its own:
552
+ # "One owner, one repository, one weekly prune." or, after a colon,
553
+ # "the setup is narrow: one reviewer, one queue, one deadline". The
554
+ # determiner is the cadence; the comma is the evidence of authored
555
+ # parallelism, as in no-x-no-y.
556
+ #
557
+ # The chain must open a sentence or follow a colon, semicolon or dash.
558
+ # That is the narrowing that separates the drumbeat from counting: "the
559
+ # flat has one bedroom, one bathroom, one balcony" and "add one egg, one
560
+ # onion, one carrot" hang off a verb, and there the word is doing
561
+ # arithmetic. Each item is "one" plus one or two letter-led words (never
562
+ # "and"/"or"), so an enumeration over numbers is not a chain; the
563
+ # Oxford comma is optional after the first link; a gap may cross a
564
+ # hard-wrapped newline but never a paragraph break; and the last item
565
+ # must run into punctuation, a joining word, or the end of the text, so
566
+ # the excerpt never carries the opening of the next clause. The
567
+ # distributive "one for you, one for me, one for the pot" is skipped at
568
+ # any length, and a pair never flags: two is distribution, three is a
569
+ # drumbeat.
570
+ #
571
+ # Verse keeps the shape on purpose ("One face, one voice, one habit, and
572
+ # two persons"), and that is an accepted cost.
573
+ pattern: /(?:^|(?<=[.!?:;—–])[ \t]{1,2})\K
574
+ one[ \t]+(?=[a-z])[\w'’-]+(?:[ \t]+(?!and\b|or\b)(?=[a-z])[\w'’-]+)?
575
+ ,(?:[ \t]|\r?\n(?!\s*\n))+(?:and(?:[ \t]|\r?\n(?!\s*\n))+)?
576
+ one[ \t]+(?=[a-z])[\w'’-]+(?:[ \t]+(?!and\b|or\b)(?=[a-z])[\w'’-]+)?
577
+ (?:(?:,(?:[ \t]|\r?\n(?!\s*\n))+(?:and(?:[ \t]|\r?\n(?!\s*\n))+)?|(?:[ \t]|\r?\n(?!\s*\n))+and(?:[ \t]|\r?\n(?!\s*\n))+)
578
+ one[ \t]+(?=[a-z])[\w'’-]+(?:[ \t]+(?!and\b|or\b)(?=[a-z])[\w'’-]+)?)+
579
+ (?=[^\w\s'’-]|\s+(?:and|or|but|in|on|at|by|with|for|to|of|from|per|each|that|which|who|when|where|so|because|is|are|was|were)\b|\s*\z)/ix,
580
+ message: '"one X, one Y, one Z" chain (%{count} items) reads as AI cadence.',
581
+ suggestion: "Cut the chain or make it one plain sentence.",
582
+ # Item heads only, so a "one" inside an item is not an item.
583
+ count_group: /(?:\A|,\s+(?:and\s+)?|\s+and\s+)one\b/i,
584
+ # The distributive frame, at any length.
585
+ skip: [/(?:\bone\s+for\b[\s\S]*?){3}/i],
586
+ examples_bad: [
587
+ "The setup is deliberately narrow: one reviewer, one queue, one deadline.",
588
+ "One owner, one repository, one weekly prune.",
589
+ # No Oxford comma.
590
+ "One name, one number and one date.",
591
+ # A curly apostrophe is a word character here.
592
+ "One team’s lead, one owner, one date.",
593
+ # A hard-wrapped chain survives one newline.
594
+ "One owner, one repository,\none weekly prune."
595
+ ],
596
+ examples_ok: [
597
+ "One for you, one for me.",
598
+ "One for you, one for me, one for the pot.",
599
+ "We keep three bins: one for 2019, one for 2020, one for 2021.",
600
+ "One of them left, and the other one stayed.",
601
+ # After a verb the word is counting.
602
+ "The flat has one bedroom, one bathroom, one balcony.",
603
+ "Add one egg, one onion, one carrot and simmer for an hour.",
604
+ "The season ended with one win, one loss, one draw.",
605
+ # Essays: First Series (Emerson, public domain): not at a clause start.
606
+ "Not for nothing one face, one character, one fact, makes much impression on him",
607
+ # Items in separate sentences never chain.
608
+ "One person wrote it in the morning. One person read it after lunch. One person filed it at the end of the day.",
609
+ # A paragraph break ends the chain.
610
+ "He carried one bag, one coat,\n\nOne question remained unanswered."
611
+ ],
612
+ rationale: "A drumbeat of 'one' items is a model's way of making a setup sound " \
613
+ "spare and inevitable; the word does no counting, it sets a rhythm. " \
614
+ "The chain has to stand on its own, at a sentence start or after a " \
615
+ "colon, because after a verb the word is counting and the shape is " \
616
+ "ordinary. Careful writers distribute in pairs and rarely stack three."
617
+ ),
618
+ Rule.new(
619
+ id: "and-what-it-should",
620
+ category: "rhetorical-tic",
621
+ severity: "warning",
622
+ # The elliptical tail: "List what the assistant knows about the client,
623
+ # and what it should." The second "what" clause borrows its verb from the
624
+ # first and ends on a bare modal or a negated auxiliary, so the sentence
625
+ # closes on a contrast it never states. The comma, the conjunction, and
626
+ # the full stop right after the modal are all required; "and what it
627
+ # should do" is a complete clause and does not flag. The affirmative
628
+ # copula and do-verb ("and what he does.", "and what it is.") are left
629
+ # out: those are complete clauses with a main verb, not ellipsis. A
630
+ # question mark is left out too, because an interrogative licenses the
631
+ # ellipsis ("what does it cover, and what doesn't it?"). Gaps may cross
632
+ # a hard-wrapped newline but never a paragraph break.
633
+ pattern: /,(?:[ \t]|\r?\n(?!\s*\n))+(?:and|but|or)(?:[ \t]|\r?\n(?!\s*\n))+what(?:[ \t]|\r?\n(?!\s*\n))+(?:it|they|you|we|he|she|one|i)(?:[ \t]|\r?\n(?!\s*\n))+
634
+ (?:(?:should|could|would|must|can|will|might|may)(?:(?:[ \t]|\r?\n(?!\s*\n))+not|n['’]t)?
635
+ |cannot|(?:does|did|is|was|has|have|had|are|were)(?:(?:[ \t]|\r?\n(?!\s*\n))+not|n['’]t))[.!]/ix,
636
+ message: '"…, and what it should." is the AI elliptical tail.',
637
+ suggestion: "Finish the clause, or cut it.",
638
+ examples_bad: [
639
+ "List what the assistant knows about the client, and what it should.",
640
+ "List what the tool does, and what it doesn't.",
641
+ "List what the tool does, and what it does not.",
642
+ "Say what the team decided, but what it couldn't.",
643
+ "List what I know about the client, and what I should.",
644
+ "List what we cover, and what we haven't.",
645
+ # A hard-wrapped tail survives one newline.
646
+ "List what the model knows about the client,\nand what it should."
647
+ ],
648
+ examples_ok: [
649
+ "List what the assistant knows about the client, and what it should know.",
650
+ "He asked what it was, and what it should be called.",
651
+ "Nobody knew what it cost or what it should.",
652
+ "She wrote down what she saw. And what she should have seen, she added later.",
653
+ # A main verb is a complete clause.
654
+ "It is not what he says, but what he does.",
655
+ "They knew what he did, and what he was.",
656
+ # A question licenses the ellipsis.
657
+ "Who decides what the policy covers, and what it doesn't?",
658
+ # A paragraph break is not a comma.
659
+ "List what the assistant knows,\n\nand what it should."
660
+ ],
661
+ rationale: "Ending on a bare modal makes the reader supply the verb and the " \
662
+ "contrast, which reads as poise in a model and as an unfinished sentence " \
663
+ "in a person. Careful writers finish the clause."
664
+ ),
665
+ Rule.new(
666
+ id: "abstract-lives-in",
667
+ category: "rhetorical-tic",
668
+ severity: "info",
669
+ # An abstraction given an address: "the craft that lives between the two
670
+ # desks", "its context lives in a folder nobody else can open", "the
671
+ # value sits in the follow-up". The subject list is closed and abstract,
672
+ # so a person, a dog, or a house living or sitting somewhere never
673
+ # matches, and a capitalised subject is skipped, so the messenger app
674
+ # Signal sitting in the middle of a relay is not the figure (at the cost
675
+ # of a sentence-initial "Context lives in", which is rare).
676
+ #
677
+ # Two prepositions are left out. "with" marks responsibility ("the
678
+ # decision sits with the board"), and "at" marks a quantity ("the value
679
+ # sits at ten million"); "at the intersection of" belongs to
680
+ # intersection-of. Two verbs are left out too: "lies in" ("the problem
681
+ # lies in the assumption") and "resides in" are ordinary English for
682
+ # where a fault or an authority is, at any register.
683
+ #
684
+ # Ships at info. The same shape states where information literally is
685
+ # ("the knowledge lives in our heads", "the instructions live in the
686
+ # README"), and a sample of pre-2022 Hacker News biased toward the
687
+ # construction turns up a few of those per million words, all human.
688
+ # One flag is a question; a draft that keeps giving ideas addresses
689
+ # should be read as a warning.
690
+ pattern: /\b(?:work|context|knowledge|answer|value|truth|problem|risk|decision|instructions?|memory|leverage|power|magic|difference|opportunity|insight|advantage|gap|signal|nuance|meaning|tension|friction|complexity|craft|skill|expertise)(?:[ \t]|\r?\n(?!\s*\n))+
691
+ (?:that(?:[ \t]|\r?\n(?!\s*\n))+|which(?:[ \t]|\r?\n(?!\s*\n))+)?(?:lives?|lived|sits?|sat)(?:[ \t]|\r?\n(?!\s*\n))+(?:in|between|inside|outside|beneath|behind|under|underneath)\b/ix,
692
+ message: "An abstraction that lives/sits somewhere is an AI figure.",
693
+ suggestion: "Say who does the work, or where the thing actually is.",
694
+ # A capitalised subject is a proper noun.
695
+ skip: [/\A[A-Z]/],
696
+ examples_bad: [
697
+ "The craft that lives between the two desks is where the handoff fails.",
698
+ "Its context lives in a folder nobody else can open.",
699
+ "The real value sits in the follow-up, not the meeting."
700
+ ],
701
+ examples_ok: [
702
+ "She lives in Boston and sits between us at dinner.",
703
+ # Responsibility, not location.
704
+ "The decision sits with the board.",
705
+ "The risk lives with the buyer once the goods ship.",
706
+ # A quantity, not a location.
707
+ "The value sits at ten million dollars a life.",
708
+ # "on" is not in the list.
709
+ "The knowledge lived on a server in the basement.",
710
+ # An intervening noun breaks the figure.
711
+ "The knowledge base lived in the basement.",
712
+ # A proper noun.
713
+ "Signal sits in the middle of the relay.",
714
+ # "lies in" is ordinary English for where a fault is.
715
+ "The problem lies in the assumption.",
716
+ "He put the answer in the margin and sat between the two of them."
717
+ ],
718
+ rationale: "Giving an idea a location ('the value sits in', 'the work lives " \
719
+ "between') lets a writer sound precise about where something is without " \
720
+ "saying who does it or what it is. Models reach for it constantly, but " \
721
+ "people use the same shape to say where information literally is, so " \
722
+ "one flag is a question; a draft that keeps giving ideas addresses should " \
723
+ "be read as a warning."
724
+ ),
725
+ Rule.new(
726
+ id: "the-x-is-the-x",
727
+ category: "rhetorical-tic",
728
+ severity: "warning",
729
+ # The repeated-head equative: "the reason it holds up is the reason the
730
+ # other half happens", "the problem with A is the problem with B". The
731
+ # same abstract head noun sits on both sides of the copula, caught by a
732
+ # backreference, so the sentence equates two things by declaring them
733
+ # the same kind of thing.
734
+ #
735
+ # The noun list is closed and holds only heads that cannot name a
736
+ # specific object: reason, problem, question, lesson and their kin.
737
+ # "key", "cost", "value", "unit", "fix" and the like are out, because
738
+ # "the key to the front door is the key on the red fob" is an identity
739
+ # statement that tells the reader something. The clause between the
740
+ # two heads is capped at fifty characters and may not hold a comma,
741
+ # semicolon or colon, so the two heads sit in one clause and an earlier
742
+ # "the cost was low, but shipping is the cost" never pairs; it may
743
+ # cross a hard-wrapped newline. The second head must be followed by a
744
+ # preposition, determiner, quantifier, pronoun, plural noun, or
745
+ # punctuation, so a compound ("the answer key") is not a repeat. The
746
+ # bare form ("the reason is the reason we came") and the contracted
747
+ # negation ("isn't the reason") both count. The sentence-initial
748
+ # capital is not required, so "and the reason … is the reason …"
749
+ # flags too.
750
+ pattern: /\bthe\s+(reason|problem|question|lesson|difference|trick|move|goal|tell|pattern|insight|takeaway|shift|bet|catch|bottleneck|issue|game|cause|failure|magic|challenge|tension|irony|paradox|trap)\b
751
+ (?:[^.!?;:,\n]|\r?\n(?!\s*\n)){1,50}?\bis(?:n['’]t|\s+not)?\s+the\s+\1
752
+ (?=[,.;:!?]|\s+(?:of|for|with|in|on|to|at|behind|about|that|which|why|here|there|the|a|an|this|these|those|my|our|your|their|its|his|her|it|they|we|you|i|he|she|every|each|most|many|some|any|no|nobody|everyone|people|[a-z]+s)\b)/ix,
753
+ message: '"The X … is the X …" equates by repeating the head noun.',
754
+ suggestion: "Say what the second thing is, not that it is the same kind of thing.",
755
+ examples_bad: [
756
+ "The reason it holds up is the reason the other half happens.",
757
+ "The problem with the tool is the problem with the team.",
758
+ "In practice the question for them is not the question for us.",
759
+ "The problem with the tool isn't the problem with the team.",
760
+ # The bare form.
761
+ "The reason is the reason we came.",
762
+ # A quantifier or a plural noun may follow the second head.
763
+ "The reason people stay is the reason people leave.",
764
+ "The problem with onboarding is the problem every team has.",
765
+ "The lesson from the outage is the lesson most teams skip.",
766
+ # A hard-wrapped clause survives one newline.
767
+ "The reason\nit holds is the reason it fails."
768
+ ],
769
+ examples_ok: [
770
+ "Rain is the reason we stayed home.",
771
+ "Its cost is the reason we came late.",
772
+ # Across a sentence boundary is two sentences.
773
+ "The reason is simple. It is the reason we left.",
774
+ # A compound noun is not a repeated head.
775
+ "The answer to the first question is the answer key, not a guess.",
776
+ # A semicolon or a comma is a boundary: the heads must share a clause.
777
+ "Process is the issue; community process is the issue we can fix.",
778
+ "The cost was low, but shipping is the cost we forgot.",
779
+ "The reason was never clear, but timing is the reason it failed.",
780
+ # Concrete heads are identity statements, not tautologies.
781
+ "The key to the front door is the key on the red fob.",
782
+ "The cost of shipping is the cost of the box plus postage."
783
+ ],
784
+ rationale: "Repeating the head noun across the copula asserts an identity between " \
785
+ "two things while naming neither; it sounds like a diagnosis and " \
786
+ "delivers a tautology. Careful writers say what the second thing is."
787
+ ),
788
+ Rule.new(
789
+ id: "same-determiner-chain",
790
+ category: "rhetorical-tic",
791
+ severity: "info",
792
+ # The quiet cousin of one-x-one-y and no-x-no-y: three or more items in
793
+ # a comma chain that all open on the same determiner or quantifier,
794
+ # caught with a backreference: "every faculty, every thought, every
795
+ # emotion", "your inbox, your calendar, your task list", "more work,
796
+ # more meetings, more email". "one" and "no" are left to their own
797
+ # rules. The narrative possessives (my, his, her, their, its) are left
798
+ # out: "his fame, his position, his life" is every novelist's, and it
799
+ # doubled the human rate. Ships at info because this is a rhetorical
800
+ # device humans own too, Emerson above all; a model uses it the way it
801
+ # uses the others, as a default cadence, and several in one draft is
802
+ # the tell. The Oxford comma is optional after the first link, a gap may
803
+ # cross a hard-wrapped newline but never a paragraph break, and the last
804
+ # item must run into punctuation, a joining word, a modal or common
805
+ # verb, or the end of the text. A verb or adverb the list does not know
806
+ # can still be swallowed as the last item's second word ("every deploy
807
+ # today"); the count is right and the over-reach is one word. The
808
+ # letter-led guards are case-sensitive on purpose: under /i they would
809
+ # let "New York, New Jersey, New Hampshire" through as a chain.
810
+ pattern: /\b(every|each|your|our|more|less|fewer|same|any|another|zero|new|real|true)[ \t]+(?-i:(?=[a-z]))[\w'’-]+(?:[ \t]+(?!and\b|or\b)(?-i:(?=[a-z]))[\w'’-]+)?
811
+ ,(?:[ \t]|\r?\n(?!\s*\n))+(?:and(?:[ \t]|\r?\n(?!\s*\n))+)?
812
+ \1[ \t]+(?-i:(?=[a-z]))[\w'’-]+(?:[ \t]+(?!and\b|or\b)(?-i:(?=[a-z]))[\w'’-]+)?
813
+ (?:(?:,(?:[ \t]|\r?\n(?!\s*\n))+(?:and(?:[ \t]|\r?\n(?!\s*\n))+)?|(?:[ \t]|\r?\n(?!\s*\n))+and(?:[ \t]|\r?\n(?!\s*\n))+)
814
+ \1[ \t]+(?-i:(?=[a-z]))[\w'’-]+(?:[ \t]+(?!and\b|or\b)(?-i:(?=[a-z]))[\w'’-]+)?)+
815
+ (?=[^\w\s'’-]|\s+(?:and|or|but|in|on|at|by|with|for|to|of|from|per|than|that|which|who|when|where|so|because|is|are|was|were|will|would|can|could|should|must|may|might|has|have|had|do|does|did|all|every|each|need|needs|make|makes|bring|brings|matter|matters|today|now|then|here|there)\b|\s*\z)/ix,
816
+ message: 'Repeated-determiner chain (%{count} items) reads as AI cadence.',
817
+ suggestion: "Cut the chain or make it one plain sentence.",
818
+ count_group: /(?:\A|,\s+(?:and\s+)?|\s+and\s+)(?:every|each|your|our|more|less|fewer|same|any|another|zero|new|real|true)\b/i,
819
+ examples_bad: [
820
+ "It touches every file, every branch, every deploy.",
821
+ "You get more work, more meetings, and more email.",
822
+ "Your inbox, your calendar, your task list.",
823
+ # No Oxford comma.
824
+ "It touches every file, every branch and every deploy.",
825
+ # A chain as the subject of a clause.
826
+ "Every test, every lint, every build must pass.",
827
+ "You get more work, more meetings, and more email every day.",
828
+ # A hard-wrapped chain survives one newline.
829
+ "Every file, every branch,\nevery deploy."
830
+ ],
831
+ examples_ok: [
832
+ "Every file and every branch was checked.",
833
+ # Two items is a pair, not a chain.
834
+ "Every file, every branch.",
835
+ # The determiner must repeat; a mixed list is a list.
836
+ "Every file, each branch, and some deploys.",
837
+ "We bought more bread, some milk, and a dozen eggs.",
838
+ # A paragraph break ends the chain.
839
+ "Every file, every branch,\n\nEvery deploy was checked twice.",
840
+ # Narrative possessives are left out.
841
+ "His coat, his hat, his gloves.",
842
+ # Proper nouns are not a chain.
843
+ "We visited New York, New Jersey, and New Hampshire."
844
+ ],
845
+ rationale: "A comma chain of items opening on the same determiner is a drumbeat, " \
846
+ "and a model falls into it as a default cadence. It is also a device " \
847
+ "careful writers use on purpose, so one is a question, not a verdict; " \
848
+ "when a draft carries several, read the family as a warning."
849
+ ),
105
850
  Rule.new(
106
851
  id: "dont-verb-it",
107
852
  category: "rhetorical-tic",
@@ -175,25 +920,50 @@ module Sloplint
175
920
  Rule.new(
176
921
  id: "cleanly",
177
922
  category: "rhetorical-tic",
178
- severity: "warning",
179
- # No verb list. The engineering idioms -- a patch applies cleanly, a
180
- # branch merges cleanly, a build compiles cleanly -- are the same move
181
- # and get flagged too, on purpose. The word survives in a 19th-century
182
- # adjective sense ("cleanly dressed", "a cleanly laid table") that the
183
- # rule also catches, which costs nothing: nobody writes that today.
184
- pattern: /\bcleanly\b/i,
923
+ severity: "info",
924
+ # The bare adverb was the whole rule, and the engineering idioms were
925
+ # flagged on purpose. Technical prose says that was the wrong call: a
926
+ # patch that applies cleanly, a build that compiles cleanly and a gear
927
+ # leg that separates cleanly are all checkable facts with a visible
928
+ # failure state, which is the opposite of rating a fit you haven't
929
+ # shown. The original probe -- 25 Gutenberg texts, no modern manner
930
+ # adverb found -- could not have caught this, because Victorian novels
931
+ # have no builds; the same corpus read as engineering ("clearly and
932
+ # cleanly drawn in pencil", Machine Drawing and Design, 1890) has it.
933
+ #
934
+ # What is left is the partition frame: something divided into parts, or
935
+ # mapped onto another scheme, and rated before the reader sees the
936
+ # parts. The clause-final form ("the objection breaks down cleanly, and
937
+ # neither half survives") is the same tell and is left out.
938
+ #
939
+ # The frame is not the sense, and this rule ships at info because of it.
940
+ # "The argument splits cleanly into two parts" and "the gear retracted
941
+ # cleanly into the well" are one pattern apart only in their subject,
942
+ # and a regex cannot see the subject -- the same limit that keeps the
943
+ # animacy verbs out of trailing-significance-participle. The preposition
944
+ # buys the common physical cases ("separated cleanly from", "cleanly
945
+ # compiled", "cleanly drawn") and nothing more, so a flag here is a
946
+ # question for the agent reading it, not a verdict.
947
+ pattern: /\bcleanly\s+(?:in(?:to|\s+(?:two|three|half))|onto)\b/i,
185
948
  message: '"Cleanly" rates the fit instead of showing it.',
186
949
  suggestion: "Cut the adverb, or say what actually lined up.",
187
950
  examples_bad: [
188
951
  "The argument splits cleanly into two parts.",
189
- "The patch applies cleanly to main.",
190
952
  "The new taxonomy maps cleanly onto the old one.",
191
- "The objection breaks down cleanly, and neither half survives."
953
+ "The objection divides cleanly in half, and neither side survives."
192
954
  ],
193
955
  examples_ok: [
194
956
  "The branch merged without conflicts.",
195
957
  "She wiped the counter clean.",
196
- "The build finished with no warnings."
958
+ "The build finished with no warnings.",
959
+ # The engineering idiom the rule used to flag on purpose. A patch that
960
+ # applies cleanly either did or did not; there is nothing being rated.
961
+ "The patch applies cleanly to main.",
962
+ # Wording follows NTSB AAR-14/01 and NASA SEL-84-101 (US government
963
+ # works) and An Introduction to Machine Drawing and Design (1890).
964
+ "The main landing gear separated cleanly from the airplane.",
965
+ "The code should be cleanly compiled beforehand.",
966
+ "Your answers should be clearly and cleanly drawn in pencil."
197
967
  ],
198
968
  rationale: "The adverb rates the join instead of showing it, and it rates before the " \
199
969
  "reader has anything to check. Real material resists -- the leftover case, " \
@@ -235,9 +1005,9 @@ module Sloplint
235
1005
  # in front of a speech verb, and the concrete-capable nouns (cut, line,
236
1006
  # version, split) are left out entirely.
237
1007
  pattern: /\bcleanest\s+(?:\w+\s+){0,2}
238
- (?:framing|formulation|statement|account|argument|idea|definition|summary
239
- |reading|take|point|story|explanation|distinction|comparison|mapping
240
- |abstraction)\b
1008
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|comparison
1009
+ |statement|account|argument|idea|definition|summary
1010
+ |reading|take|point|story|explanation|distinction)\b
241
1011
  |\bcleanest\s+way\s+to\s+(?:say|put|frame|state|describe|phrase|express
242
1012
  |think\s+about)\b/ix,
243
1013
  message: '"The cleanest framing/way to put it…" ranks your own claim for the reader.',
@@ -265,7 +1035,7 @@ module Sloplint
265
1035
  # "break" is admitted only in "clean break between", never bare, because
266
1036
  # "make a clean break with the past" is an idiom and not a tell.
267
1037
  pattern: /\b(?:a|the|one)\s+(?:\w+\s+)?clean\s+(?:\w+\s+)?
268
- (?:abstraction|distinction|framing|formulation|mapping|through-line
1038
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|distinction|through-line
269
1039
  |story|answer|argument|split|divide)\b
270
1040
  |\bclean\s+(?:line|break|split)\s+between\b/ix,
271
1041
  message: '"A clean abstraction / clean framing" praises the idea instead of showing it.',
@@ -340,6 +1110,104 @@ module Sloplint
340
1110
  "are three words apart and identical on the surface. An agent reading the flag " \
341
1111
  "has the rest of the sentence to judge; the pattern alone doesn't."
342
1112
  ),
1113
+ Rule.new(
1114
+ id: "real-x-real-y",
1115
+ category: "rhetorical-tic",
1116
+ severity: "info",
1117
+ # Ships at info, not warning: the only two hits the probe found in
1118
+ # 1.28M words were both false positives, and one corpus of one
1119
+ # register is thin evidence next to honestly, which sits at warning
1120
+ # on 9.7M words across two. A doubled "real" is also defensible on
1121
+ # its own ("No real financial loss, but a real failure") in a way
1122
+ # the warning rules around it are not. The agent reading the flag
1123
+ # decides.
1124
+ #
1125
+ # "If it can trigger real API calls or hold real credentials, it needs
1126
+ # the same rigor." -- the same intensifier, said twice in one sentence,
1127
+ # each time in front of a different noun. Bare "real" is an ordinary
1128
+ # word (a real number, real time, a real problem) and cannot be flagged
1129
+ # on its own; the narrowing here is repetition, not a noun list --
1130
+ # requiring two attributive uses in one sentence, naming two different
1131
+ # things, is what tells the doubled intensifier apart from plain
1132
+ # English, and it needs no list of nouns to do it.
1133
+ #
1134
+ # "Attributive" is enforced by requiring "real" to run straight into
1135
+ # the word it modifies: a space, then a letter, with no comma or
1136
+ # conjunction between. That already keeps the predicative use out
1137
+ # ("The risk is real, and it is growing" -- its own rule, above) since
1138
+ # a predicate "real" is followed by punctuation or "and", never
1139
+ # directly by the next noun.
1140
+ #
1141
+ # A hyphen touching "real" on either side takes it out of the running:
1142
+ # "real-time" and "real-world" are compounds, one modifier, not two
1143
+ # independent uses of the intensifier, and "non-real" is a negation,
1144
+ # not the intensifier at all. (?<!-) and (?!-) drop all three.
1145
+ #
1146
+ # The second "real" must name a word the first one didn't -- a
1147
+ # backreference, so "a real risk ... that real risk" (the same thing,
1148
+ # referred to twice) is ordinary reference, not the tic. Two mentions
1149
+ # only look alike when they're both new: two different real things.
1150
+ #
1151
+ # The closed list after each "real" (REAL_X_REAL_Y_EXCLUDED_NEXT_WORD,
1152
+ # above) is the handful of fixed senses that are common in this
1153
+ # register and are never the tell, plus the function words that only
1154
+ # ever continue a predicative "is real". This gives up "real user"
1155
+ # outside "real user monitoring" and any doubled "real name"/"real
1156
+ # names" pairing that differs only by a plural -- known, deliberate
1157
+ # recall losses rather than a growing list.
1158
+ #
1159
+ # The gap between the two "real"s crosses a hard-wrapped line the same
1160
+ # way WRAP_GAP does (a newline is fine unless it opens a paragraph
1161
+ # break, PARA_BREAK, since a non-breaking space alone on the line in
1162
+ # between still reads as a blank line to the editors that emit one),
1163
+ # but never a sentence-ending mark, so the two "real"s must fall in
1164
+ # one sentence.
1165
+ pattern: /
1166
+ (?<!-)\breal(?!-)[ \t]+
1167
+ (?!(?:#{REAL_X_REAL_Y_EXCLUDED_NEXT_WORD})\b)
1168
+ (?>([a-z][\w'’-]*))\b
1169
+ (?:(?!\breal\b)[^.!?\n]|(?!#{PARA_BREAK})\r?\n){1,200}?
1170
+ (?<!-)\breal(?!-)[ \t]+
1171
+ (?!(?:#{REAL_X_REAL_Y_EXCLUDED_NEXT_WORD})\b)
1172
+ (?!\1\b)
1173
+ (?>[a-z][\w'’-]*)\b
1174
+ /ix,
1175
+ message: 'Doubled "real" ("real X … real Y") repeats the intensifier for emphasis.',
1176
+ suggestion: "Cut one 'real,' or say what actually makes each thing real -- a name, a number, a log line.",
1177
+ examples_bad: [
1178
+ "If it can trigger real API calls or hold real credentials, it needs the same rigor.",
1179
+ "The demo used real customer data and exposed real financial records to the whole team.",
1180
+ "This isn't a mockup; it hits a real database and charges a real credit card.",
1181
+ "The incident caused real financial losses and real reputational damage to the company."
1182
+ ],
1183
+ examples_ok: [
1184
+ # The second "real" must name something the first didn't.
1185
+ "The system exposes a real risk, and that real risk must be tracked.",
1186
+ # A hyphen on either side makes it a compound, not two uses.
1187
+ "The dashboard shows real-time metrics and real-time alerts.",
1188
+ # "real world" is on the closed list even unhyphenated.
1189
+ "In the real world this rarely happens, and the real world rewards patience.",
1190
+ # The math sense, both nouns.
1191
+ "The equation has two real roots and no real numbers outside that range.",
1192
+ # "real user" (monitoring) and a hyphenated compound together.
1193
+ "The tool combines real user monitoring with real-time dashboards.",
1194
+ # Two more closed-list nouns.
1195
+ "She invested in real estate and lost real money.",
1196
+ # "non-real" is a negation, not a second use of the intensifier.
1197
+ "The audit flagged real deployment risk but no non-real anomalies.",
1198
+ # Only one "real" in the sentence.
1199
+ "This is a real problem worth solving.",
1200
+ # A paragraph break -- even one where the blank line holds only a
1201
+ # non-breaking space -- ends the sentence; the two paragraphs are
1202
+ # never joined into one hit.
1203
+ "This plan needs real signoff\n \nbefore it touches real production data."
1204
+ ],
1205
+ rationale: "\"Real\" is the plainest way to say a thing isn't fake or hypothetical, and it " \
1206
+ "only needs saying once a sentence -- naming it again in front of a second noun " \
1207
+ "doesn't add information, it repeats the reassurance. A person defending a claim " \
1208
+ "from two directions in the same breath writes the two facts and lets one " \
1209
+ "\"real\" cover both."
1210
+ ),
343
1211
  Rule.new(
344
1212
  id: "the-punchline-is",
345
1213
  category: "rhetorical-tic",
@@ -516,7 +1384,64 @@ module Sloplint
516
1384
  id: "exact-exactly",
517
1385
  category: "rhetorical-tic",
518
1386
  severity: "info",
519
- pattern: /\bexact(?:ly)?\b(?!\s*(?:(?:the\s+)?(?:same|opposite|science|change|replica|copy|location|coordinates|way)\b|[$\d]|noon\b|midnight\b|o'?\s*clock\b))/i,
1387
+ # Rewritten after 2.2M words of technical prose left ~111 of 125 hits
1388
+ # false. The shape was the problem, not the entries: the pattern matched
1389
+ # "exact" everywhere and subtracted an allow-list, and an open pattern
1390
+ # with a growing exception list can never close over a common word.
1391
+ # Machining and investigation prose puts "exact" in front of anything an
1392
+ # instrument reads -- diameter, pitch angle, CPU time, blade setting,
1393
+ # five threads, perpendicular, on center -- so each addition bought one
1394
+ # false positive and risked silencing the cliche sitting behind it.
1395
+ #
1396
+ # So the rule now matches the tell instead, and the tell is a small
1397
+ # closed set of frames: a demonstrative copula carrying the intensifier
1398
+ # ("that's exactly", "this is exactly the kind of"), the bare evaluative
1399
+ # complement ("exactly right"), the deictic noun ("exactly the point",
1400
+ # "the exact problem"), and a knowing verb plus a wh-word ("I know
1401
+ # exactly why"). Everything else is silent by construction rather than
1402
+ # by exception.
1403
+ #
1404
+ # Two recall losses, both deliberate. "exactly the right words" is the
1405
+ # tell and "reamed to exactly the right size" is a measurement, and they
1406
+ # differ only in their subject, which a regex cannot see -- so that
1407
+ # frame stays out unless a copula carries it. And "determined exactly
1408
+ # when" is precise where "know exactly why" is filler, so only the
1409
+ # knowing verbs take the wh-branch.
1410
+ pattern: /\b(?:
1411
+ # "That's exactly ...", "This is exactly the kind of ...".
1412
+ # The complement is open here, so it needs the checkable
1413
+ # allow-list back as a guard, or the copula re-admits every
1414
+ # measurement the rest of the rule was rewritten to drop:
1415
+ # "That's the exact same design", "It was exactly noon".
1416
+ # "here" and "there" are not subjects: the existential
1417
+ # "there is exactly one solution" is a count, so checkable.
1418
+ (?:that|this|it|these|those|which)
1419
+ (?:'s|\u2019s|\s+(?:is|was|are|were))\s+
1420
+ (?:the\s+)?exact(?:ly)?\b
1421
+ (?!\s*(?:
1422
+ (?:the\s+)?
1423
+ (?:same|opposite|science|change|replica|cop(?:y|ies)|
1424
+ location|coordinates|way)\b
1425
+ |[$\d]|noon\b|midnight\b|o'?\s*clock\b
1426
+ |(?:one|two|three|four|five|six|seven|eight|nine|ten|
1427
+ eleven|twelve|dozen|twenty|thirty|fifty|hundred)\b
1428
+ |(?:the\s+|a\s+|an\s+)?(?:[\w-]+\s+){0,2}
1429
+ (?:diameter|radius|size|dimensions?|length|width|
1430
+ height|depth|thickness|weight|mass|volume|
1431
+ angle|pitch|temperature|pressure|speed|altitude|
1432
+ position|distance|clearance|tolerance)\b))
1433
+ # "exactly right", "exactly backwards"
1434
+ | exactly\s+(?:right|wrong|backwards|so)\b
1435
+ # "exactly the point", "the exact problem"
1436
+ | exactly\s+the\s+
1437
+ (?:point|problem|issue|reason|kind|sort|type|thing|
1438
+ question|shape|word)\b
1439
+ | the\s+exact\s+
1440
+ (?:point|problem|issue|reason|thing|question|shape)\b
1441
+ # "I know exactly why this happened"
1442
+ | know(?:s|n)?\s+exactly\s+(?:why|what|how|who)\b
1443
+ | knew\s+exactly\s+(?:why|what|how|who)\b
1444
+ )/ix,
520
1445
  message: '"exact/exactly" is reflexive emphasis unless it names something checkable.',
521
1446
  suggestion: "Cut it, or replace with the number, name, or match it's supposed to be precise about.",
522
1447
  examples_bad: [
@@ -539,7 +1464,25 @@ module Sloplint
539
1464
  "The train left at exactly noon.",
540
1465
  "They agreed to meet at exactly midnight.",
541
1466
  "The meeting starts at exactly 3 o'clock.",
542
- "She has exacting standards for her students."
1467
+ "She has exacting standards for her students.",
1468
+ # A measured quantity and a determined fact, pinning the two branches
1469
+ # added above. Wording follows "Turning and Boring" (1919, public
1470
+ # domain by date) and NTSB AAR-04/01 (US government work).
1471
+ "The soft jaws are bored to the exact diameter of the finished rim.",
1472
+ "Investigators could not determine the exact blade pitch angle.",
1473
+ "The bore is finished with a reamer to exactly the right size and taper.",
1474
+ "It could not be determined exactly when the fire began.",
1475
+ # The demonstrative copula leaves its complement open, so each of the
1476
+ # checkable uses is pinned again in that frame -- review found the
1477
+ # branch re-admitting the whole allow-list it was meant to replace.
1478
+ "That's the exact same design we shipped last year.",
1479
+ "These are the exact coordinates of the wreck.",
1480
+ "It was exactly noon when the whistle blew.",
1481
+ "This is the exact replica of the ship.",
1482
+ # Existential "there"/"here" carry a count, which is checkable.
1483
+ "There is exactly one solution to the equation.",
1484
+ "There are exactly 24 hours in a day.",
1485
+ "Here is the exact temperature at the time of the failure."
543
1486
  ],
544
1487
  rationale: "Models reach for 'exact/exactly' as filler emphasis on a claim with nothing to " \
545
1488
  "check; it earns its place only next to a number, a name, or a stated identity."
@@ -594,6 +1537,293 @@ module Sloplint
594
1537
  "structure. Models borrow it as a metaphor for anything important, which just " \
595
1538
  "restates the sentence's importance without saying what actually holds it up."
596
1539
  ),
1540
+ Rule.new(
1541
+ id: "intersection-of",
1542
+ category: "rhetorical-tic",
1543
+ severity: "warning",
1544
+ # "at" is not load-bearing -- "explores the intersection of art and
1545
+ # technology" is the same move -- so the anchor is "the intersection of"
1546
+ # and the two guards carry the whole burden of separating the literal
1547
+ # senses. A street corner names its streets, and a street name is
1548
+ # Capitalized-then-lowercase ("Elm", "Broadway", "Highway 12"), so the
1549
+ # positive lookahead demands the next word be lowercase or an all-caps
1550
+ # acronym -- "AI", "UX", "HCI" are the metaphor's favourite operands and
1551
+ # no street is spelled that way. Geometry and set arithmetic name their
1552
+ # operands ("the two curves", "the ranges", "both key sets"), so the
1553
+ # negative lookahead drops a literal noun found within two words of
1554
+ # "of". Two words, not three, keeps "art and city streets" from reaching
1555
+ # "streets" and silencing a real hit. No /i on the whole pattern: it
1556
+ # would make [a-z] match capitals and undo the first guard, so the
1557
+ # case-insensitive parts are inline (?i:...) groups instead.
1558
+ #
1559
+ # Three additions after the rule met accident reports and reference
1560
+ # documentation, where every hit was literal and each broke a guard.
1561
+ # Airfield surfaces (runway, taxiway, apron) are as literal as a street
1562
+ # and are written lowercase, so the first guard was actively admitting
1563
+ # them. A matrix cell is the same literal sense as a set intersection,
1564
+ # so rows and columns join the geometry list. And the all-caps
1565
+ # allowance, added for "AI"/"UX"/"HCI" on the premise that no street is
1566
+ # spelled that way, was admitting American route designators. The
1567
+ # trailing (?!-\d) on that branch drops a route number ("US-27A"), and
1568
+ # one more lookahead drops a quadrant plus a house number ("NE 140th
1569
+ # Court"). Street-type nouns are deliberately NOT added to the literal
1570
+ # list: "court", "drive" and "place" are ordinary abstract nouns, and
1571
+ # the two-word window would reach them as the second operand and
1572
+ # silence "the intersection of memory and place".
1573
+ pattern: /\b[Tt]he\s+intersection\s+of\b
1574
+ (?!\s+(?:[\w-]+\s+){0,2}
1575
+ (?i:sets?|lines?|curves?|planes?|circles?|spheres?|axes|
1576
+ rays?|segments?|arcs?|orbits?|streets?|avenues?|
1577
+ roads?|highways?|routes?|tracks?|corridors?|paths?|
1578
+ boulevards?|lanes?|arrays?|lists?|ranges?|
1579
+ collections?|keys?|vectors?|matrices|polygons?|
1580
+ rectangles?|intervals?|data|datasets?|
1581
+ runways?|taxiways?|taxilanes?|aprons?|
1582
+ rows?|columns?|cells?)\b)
1583
+ (?!\s+[A-Z]{1,3}\s+\d)
1584
+ (?=\s+(?:[a-z]|[A-Z]{2,}\b(?!-\d)))/x,
1585
+ message: '"the intersection of X and Y" outside streets or geometry is borrowed positioning.',
1586
+ suggestion: "Say what the work does, or name the two things it takes from each.",
1587
+ examples_bad: [
1588
+ "Her work sits at the intersection of art and technology.",
1589
+ "Her book explores the intersection of art and technology.",
1590
+ "We operate at the intersection of AI and healthcare.",
1591
+ "The product lives at the intersection of design and engineering.",
1592
+ "At the intersection of policy and practice, nothing moves quickly.",
1593
+ "The intersection of grief and comedy is where this essay lands.",
1594
+ "The role sits at the intersection of the marketing and product teams."
1595
+ ],
1596
+ examples_ok: [
1597
+ "The accident happened at the intersection of Elm Street and Oak Avenue.",
1598
+ "Turn left at the intersection of Main and Fifth.",
1599
+ "The bus stops at the intersection of Broadway and 42nd Street.",
1600
+ "A hydrant stands at the intersection of Elm and Willow.",
1601
+ "The house sits at the intersection of Highway 12 and County Road 8.",
1602
+ "The intersection of Elm and Willow was closed for repaving.",
1603
+ "The solution lies at the intersection of the two curves.",
1604
+ "Draw a point at the intersection of the lines.",
1605
+ "The point at the intersection of two circles is equidistant.",
1606
+ "He stood at the intersection of five streets and could not choose.",
1607
+ "The village grew up at the intersection of several old roads.",
1608
+ "Snow piled up at the intersection of the roads below.",
1609
+ "Compute the intersection of the two arrays.",
1610
+ "The query returns the intersection of both key sets.",
1611
+ "The intersection of the ranges is empty.",
1612
+ # Airfield surfaces, route designators and matrix cells, each pinning
1613
+ # one of the three guards added above. Wording follows NTSB AAR-16/02,
1614
+ # AAR-14/01 and HAR-17/02 and the NASA Software Safety Guidebook
1615
+ # (NASA-GB-8719.13) -- US government works, public domain.
1616
+ "The airplane crossed the intersection of runway 13 with runway 4.",
1617
+ "Firefighters gathered near the intersection of taxiway N and taxiway F.",
1618
+ "The crash occurred at the intersection of US-27A and NE 140th Court.",
1619
+ "Traffic was heaviest at the intersection of NE 140th Court.",
1620
+ "The square at the intersection of a row and a column contains a code."
1621
+ ],
1622
+ rationale: "A street corner and a set diagram are the phrase's literal homes. Everywhere " \
1623
+ "else it claims a position without doing the work of one: the writer sits " \
1624
+ "between two fields and says nothing about either. Models open bios and " \
1625
+ "pitches with it because it sounds like a thesis while committing to nothing."
1626
+ ),
1627
+ Rule.new(
1628
+ id: "impact-verb",
1629
+ category: "rhetorical-tic",
1630
+ severity: "warning",
1631
+ # "impact" and "impacts" are also nouns, so they only count as verbs
1632
+ # behind an auxiliary or a subject pronoun. One optional pronoun may sit
1633
+ # between the auxiliary and the verb ("does this impact the date");
1634
+ # allowing a determiner in that slot instead would swallow "will the
1635
+ # impact be permanent", so the copula lookahead below backs it up.
1636
+ # "impacted"/"impacting" are verbal on their own except for the medical
1637
+ # and soil sense: the forward lookahead catches the attributive form
1638
+ # ("an impacted wisdom tooth"), but the predicate form ("the tooth was
1639
+ # impacted") needs the noun and copula pulled into the match, because
1640
+ # skip: only ever sees matched text, never surrounding context. Same
1641
+ # reason load-bearing does it, and a lookbehind that wide trips the
1642
+ # Onigmo bug documented there. The trailing (?!-) is load-bearing too:
1643
+ # every other guard is spelled with \s+, so "will impact-test the
1644
+ # housing" walks straight past them.
1645
+ #
1646
+ # Accident reports found two holes. "to impact" was in the auxiliary
1647
+ # list as an infinitive marker, so it also matched the preposition in
1648
+ # "4 seconds prior to impact" and "time to impact"; that branch now
1649
+ # stands on its own and requires a following object. And "impacted" in
1650
+ # those reports is usually one object hitting another -- an airplane
1651
+ # impacts terrain, debris impacts a wing -- which is the collision noun
1652
+ # the rule already excludes, just on the other side of the verb, so the
1653
+ # struck-object list mirrors it.
1654
+ pattern: /\b(?:
1655
+ (?:will|would|can|could|may|might|shall|should|must|does|
1656
+ do|did|doesn't|don't|didn't|won't|helps?|helped)\s+
1657
+ (?:(?:it|this|that|they|we|you)\s+)?
1658
+ impacts?
1659
+ | to\s+impacts?
1660
+ (?=\s+(?!and\b|or\b|but\b|in\b|on\b|at\b|of\b|for\b|from\b|
1661
+ with\b|during\b|after\b|before\b|than\b)\w)
1662
+ | (?:it|this|that|which|he|she)\s+impacts
1663
+ | (?:they|we|you)\s+impact
1664
+ | (?:(?:tooth|teeth|molars?|bowels?|colon|fractures?|soils?)\s+
1665
+ (?:is|are|was|were)\s+)?
1666
+ impact(?:ed|ing)
1667
+ )\b
1668
+ (?!-)
1669
+ (?!\s+(?:be|been|is|are|was|were|of|on|has|have|had)\b)
1670
+ (?!\s+(?:tooth|teeth|molars?|wisdom|canines?|bowels?|colon|
1671
+ stool|feces|fecal|fractures?|soils?|snow|ice|sediment|
1672
+ gravel|earwax|cerumen)\b)
1673
+ (?!\s+(?:the\s+|a\s+|an\s+)?(?:[\w-]+\s+)?
1674
+ (?:terrain|ground|seabed|seawall|treetops|trees?|
1675
+ grove|thicket|hillside|embankment|berm|
1676
+ escarpment|ridgeline|cliff|
1677
+ runway|taxiway|apron|tarmac|guardrail|
1678
+ fuselage|nacelle|airframe|empennage|rotor|
1679
+ windshield|bulkhead|revetment|abutment|
1680
+ wing|wingtip|stabilizer|landing\s+gear)\b)
1681
+ (?!\s+(?:statements?|assessments?|reports?|studies|study|
1682
+ analys[ie]s|evaluations?|factors?|ratings?|scores?|
1683
+ investing|investors?|funds?|bonds?|craters?|
1684
+ wrench(?:es)?|drivers?|sockets?|printers?|sprinklers?|
1685
+ resistan(?:t|ce)|tested|testing|velocit(?:y|ies)|
1686
+ forces?|energy|load(?:s|ing)?|zones?|points?|players?|
1687
+ sites?|angles?|damage|absorbers?)\b)/ix,
1688
+ skip: [/\A(?:tooth|teeth|molars?|bowels?|colon|fractures?|soils?)\s+
1689
+ (?:is|are|was|were)\s+impacted/ix],
1690
+ message: '"impact" as a verb hides which way something moved and by how much.',
1691
+ suggestion: 'Name the verb: cut, delayed, doubled, broke, raised. Or use "affected".',
1692
+ examples_bad: [
1693
+ "The outage impacted about four thousand accounts.",
1694
+ "Changing the default will impact every downstream job.",
1695
+ "How does this impact the release date?",
1696
+ "Rising rates impacted our hiring plan.",
1697
+ "The migration impacted performance across the board.",
1698
+ "This impacts every downstream job.",
1699
+ "The new policy is impacting our margins."
1700
+ ],
1701
+ examples_ok: [
1702
+ "The impact crushed the front bumper.",
1703
+ "The crater marks the point of impact.",
1704
+ "Torque the bolts with an impact wrench.",
1705
+ "The meteor impact left a ring of debris.",
1706
+ "The parachute reduces impact force on landing.",
1707
+ "The county filed an environmental impact statement.",
1708
+ "The fund runs an impact investing strategy.",
1709
+ "The journal reports a five-year impact factor.",
1710
+ "He bought an impact driver for the deck job.",
1711
+ "She is an impact player off the bench.",
1712
+ "She had an impacted wisdom tooth removed.",
1713
+ "The tooth was impacted and had to come out.",
1714
+ "The soil was impacted by years of heavy machinery.",
1715
+ "An impacted fracture heals without displacement.",
1716
+ "Will the impact be permanent?",
1717
+ "Consultants will impact-test the housing next week.",
1718
+ # One object striking another, and the preposition that was reading as
1719
+ # an infinitive. Wording follows NTSB AAR-06/03 and AAR-14/01 and the
1720
+ # Columbia Accident Investigation Board report -- US government works.
1721
+ "The airplane impacted terrain about 300 feet north of the threshold.",
1722
+ "The foam debris impacted the left wing shortly after separation.",
1723
+ "The stick shaker activated 4 seconds prior to impact.",
1724
+ "The chart plots time to impact in seconds."
1725
+ ],
1726
+ rationale: "As a verb, 'impact' reports that something changed while withholding the " \
1727
+ "direction and the size. The specific verb -- slowed, doubled, broke -- " \
1728
+ "carries what the sentence is missing, and 'affected' covers the rest. " \
1729
+ "Models reach for it because it sounds consequential at no cost."
1730
+ ),
1731
+ Rule.new(
1732
+ id: "impact-noun-vague",
1733
+ category: "puffery",
1734
+ severity: "warning",
1735
+ # 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
1738
+ # "negative" stay out of the adjective list on purpose -- they name a
1739
+ # direction, which is more than the intensity words do. "statistically"
1740
+ # is pulled into the match as an optional leading word so skip: can see
1741
+ # it; a statistically significant impact is a finding, not puffery, and
1742
+ # skip: never sees text outside the matched span.
1743
+ pattern: /\b(?:statistically\s+)?
1744
+ (?:
1745
+ (?:significant|real|meaningful|lasting|massive|huge|profound|
1746
+ big|major|tremendous|enormous|outsized|considerable|
1747
+ substantial|genuine|tangible|immense|incredible|
1748
+ remarkable)\s+
1749
+ | (?:mak(?:e|es|ing)|made|hav(?:e|es|ing)|has|had)\s+
1750
+ (?:a|an|real|some)\s+
1751
+ )
1752
+ impacts?\b(?!-)
1753
+ (?!\s+(?:statements?|assessments?|reports?|studies|study|
1754
+ analys[ie]s|evaluations?|factors?|ratings?|scores?|
1755
+ investing|investors?|funds?|bonds?|craters?|
1756
+ wrench(?:es)?|drivers?|sockets?|printers?|sprinklers?|
1757
+ resistan(?:t|ce)|tested|testing|velocit(?:y|ies)|
1758
+ forces?|energy|load(?:s|ing)?|zones?|points?|players?|
1759
+ sites?|angles?|damage|absorbers?)\b)/ix,
1760
+ skip: [/\Astatistically\s/i],
1761
+ message: '"significant/real/big impact" and "make an impact" inflate a claim without stating it.',
1762
+ suggestion: "Say what changed and by how much, or cut the sentence.",
1763
+ examples_bad: [
1764
+ "The change had a significant impact on conversion.",
1765
+ "This will have real impact for the team.",
1766
+ "The rewrite made a big impact on load times.",
1767
+ "The launch had a profound impact on morale.",
1768
+ "We want to make an impact this quarter."
1769
+ ],
1770
+ examples_ok: [
1771
+ "The study found a statistically significant impact on mortality.",
1772
+ "The impact crushed the front bumper.",
1773
+ "The helmet failed at high-impact loading.",
1774
+ "Impact-resistant polycarbonate replaced the glass.",
1775
+ "The blast impact zone extended two hundred metres.",
1776
+ "That impact rating exceeds the standard.",
1777
+ "Is this impact reversible?"
1778
+ ],
1779
+ rationale: "The adjective does the work the sentence should have done: 'significant' and " \
1780
+ "'real' assert that a change mattered without naming it or sizing it. " \
1781
+ "'Make an impact' is the same move with the noun left bare."
1782
+ ),
1783
+ Rule.new(
1784
+ id: "impact-noun-bare",
1785
+ category: "rhetorical-tic",
1786
+ severity: "info",
1787
+ # Two shapes, each needing its own anchor: a measuring verb in front, or
1788
+ # "of" behind. That is what keeps the collision sense clear without a
1789
+ # list of collision verbs -- "the impact crushed the front bumper" has
1790
+ # neither anchor, and "the point of impact" runs the other way round.
1791
+ # The verb stems end in \w*, not \w+: with \w+ the rule misses the bare
1792
+ # imperative "Consider the impact before you merge".
1793
+ pattern: /\b(?:
1794
+ (?:measur|assess|consider|understand|understood|evaluat|
1795
+ gaug|weigh|quantif|track|examin|explor|maximi[sz])\w*\s+
1796
+ (?:the|its|their|our|this|that)\s+impacts?
1797
+ | the\s+impacts?\s+of
1798
+ )\b(?!-)
1799
+ (?!\s+(?:statements?|assessments?|reports?|studies|study|
1800
+ analys[ie]s|evaluations?|factors?|ratings?|scores?|
1801
+ investing|investors?|funds?|bonds?|craters?|
1802
+ wrench(?:es)?|drivers?|sockets?|printers?|sprinklers?|
1803
+ resistan(?:t|ce)|tested|testing|velocit(?:y|ies)|
1804
+ forces?|energy|load(?:s|ing)?|zones?|points?|players?|
1805
+ sites?|angles?|damage|absorbers?)\b)/ix,
1806
+ message: '"the impact of X" defers naming what actually changed.',
1807
+ suggestion: "Name the change itself, or the number that shows it.",
1808
+ examples_bad: [
1809
+ "We measured the impact of the new onboarding flow.",
1810
+ "Consider the impact before you merge.",
1811
+ "The team is still assessing the impact.",
1812
+ "The impact of the change is hard to quantify."
1813
+ ],
1814
+ examples_ok: [
1815
+ "The impact crushed the front bumper.",
1816
+ "The crater marks the point of impact.",
1817
+ "The meteor impact left a ring of debris.",
1818
+ "Crews measured the impact crater at dawn.",
1819
+ "The panel reviewed the impact assessment before the vote.",
1820
+ "Torque the bolts with an impact wrench.",
1821
+ "Will the impact be permanent?"
1822
+ ],
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."
1826
+ ),
597
1827
  Rule.new(
598
1828
  id: "thats-how-x",
599
1829
  category: "rhetorical-tic",
@@ -948,6 +2178,136 @@ module Sloplint
948
2178
  "promises a confession that the sentence after it rarely delivers. It " \
949
2179
  "costs the writer nothing and reads as intimacy the reader did not earn."
950
2180
  ),
2181
+ Rule.new(
2182
+ id: "honestly",
2183
+ category: "rhetorical-tic",
2184
+ severity: "warning",
2185
+ # The manner adverb, the way "cleanly" is the manner adverb: hung on a
2186
+ # subject that cannot be honest. Two guards, no verb list.
2187
+ #
2188
+ # Terminal position does most of the work. The adverb must close on a
2189
+ # full stop, comma, semicolon or exclamation mark, which is what keeps
2190
+ # the ordinary intensifier out -- "I honestly think", "I honestly don't
2191
+ # know" have the word mid-clause and never match. Sentence-initial
2192
+ # "Honestly," needs no guard of its own: a full stop cannot match \w+,
2193
+ # so the sentence before it can never donate its last word.
2194
+ #
2195
+ # The guard list covers the slot immediately before the adverb, which is
2196
+ # where the discourse marker lives. "And honestly," and "Quite honestly,"
2197
+ # are how people write, and both die there.
2198
+ #
2199
+ # There is no animacy test, because no regex sees animacy. "She answered
2200
+ # honestly." matches, and so does the sentence-final hedge people write
2201
+ # in casual comments ("it's beyond boring honestly"). Both are the known
2202
+ # cost of anchoring on position alone; see rationale for the rate.
2203
+ pattern: /\b(?!(?:and|but|or|nor|yet|so|then|though|although|because|if
2204
+ |quite|more|most|very|really|pretty|fairly|too|also|just
2205
+ |only|now|again|well|i|we|you|he|she|they|it)\b)
2206
+ \w+\b[ \t]+honestly[ \t]*(?=[.,;!])/ix,
2207
+ message: '"Honestly" in the manner slot claims good faith the reader cannot check.',
2208
+ suggestion: "Cut the adverb, or show the thing that makes it honest.",
2209
+ examples_bad: [
2210
+ "These two rows compare honestly.",
2211
+ "The numbers add up honestly.",
2212
+ "The taxonomy maps honestly, which is what the table is for.",
2213
+ "The benchmark reports honestly, which is the whole point.",
2214
+ "It reads honestly; that is what carries the piece."
2215
+ ],
2216
+ examples_ok: [
2217
+ # Mid-clause: the ordinary intensifier, and the terminal guard drops it.
2218
+ "I honestly do not know.",
2219
+ "She honestly believes it will work.",
2220
+ # The discourse marker, which the guard list drops.
2221
+ "And honestly, I forgot the deadline.",
2222
+ "Quite honestly, the meeting ran long.",
2223
+ "But honestly, nobody minded."
2224
+ ],
2225
+ rationale: "The adverb rates the good faith of the writing instead of showing anything " \
2226
+ "the reader can check, and a table or a benchmark has no good faith to rate. " \
2227
+ "Position is the only anchor, so ordinary English gets caught too: a dialogue " \
2228
+ "tag (\"said Isabel honestly\") and the sentence-final hedge of casual speech " \
2229
+ "(\"it's beyond boring honestly\"). Both are rare -- twice in 3.65M words of " \
2230
+ "public-domain prose, ten times in 6.07M words of pre-2022 Hacker News."
2231
+ ),
2232
+ Rule.new(
2233
+ id: "honest-x",
2234
+ category: "rhetorical-tic",
2235
+ severity: "warning",
2236
+ # The noun list is short on purpose, and the words left out are the
2237
+ # point. "An honest answer", "an honest assessment" and "an honest
2238
+ # account" are ordinary English about people -- six hits between them in
2239
+ # 6.07M words of Hacker News, every one of them a person being truthful
2240
+ # rather than a writer praising their own framing -- so they are out.
2241
+ # "man", "mistake", "living", "work" and "opinion" never went in.
2242
+ #
2243
+ # What ships is the set that only ever appears in front of the writer's
2244
+ # own construction, and none of it appears in 9.7M words of either
2245
+ # corpus. "most" and "more" are excluded from the modifier slot so the
2246
+ # superlative belongs to most-honest-x alone.
2247
+ pattern: /\b(?:an|the|one|this|that)[ \t]+(?:(?!most\b|more\b)\w+[ \t]+)?
2248
+ honest[ \t]+(?:\w+[ \t]+)?
2249
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|comparison|accounting|through-line)\b/ix,
2250
+ message: '"An honest comparison / the honest framing" praises the writing, not the thing.',
2251
+ suggestion: "Cut the adjective and make the comparison; the reader decides if it is honest.",
2252
+ examples_bad: [
2253
+ "That gives us an honest comparison of the two.",
2254
+ "The honest framing is that nobody knew.",
2255
+ "This is an honest accounting of what the delay cost.",
2256
+ "What we want is an honest mapping from one schema to the other."
2257
+ ],
2258
+ examples_ok: [
2259
+ # The idioms, which is where "honest" lives in ordinary prose.
2260
+ "He was an honest man.",
2261
+ "It was an honest mistake.",
2262
+ "She made an honest living.",
2263
+ "That is an honest day's work.",
2264
+ # A person being truthful, which is not a claim about the writing.
2265
+ "How could you expect an honest answer to a question like that?",
2266
+ # The superlative belongs to most-honest-x.
2267
+ "The most honest framing is that we guessed."
2268
+ ],
2269
+ rationale: "\"Honest\" in front of a framing or a comparison is the writer approving " \
2270
+ "their own work, the same move \"clean\" makes one shelf over. The reader " \
2271
+ "cannot check it and it costs nothing to assert. The narrow noun list is what " \
2272
+ "separates it from the ordinary sense: the shipped pattern flags nothing in " \
2273
+ "3.65M words of public-domain prose or 6.07M words of pre-2022 Hacker News."
2274
+ ),
2275
+ Rule.new(
2276
+ id: "most-honest-x",
2277
+ category: "rhetorical-tic",
2278
+ severity: "warning",
2279
+ # The superlative frame carries the tell on its own, so this noun list is
2280
+ # wider than honest-x's -- "the most honest assessment" is self-ranking
2281
+ # in a way "an honest assessment" is not. The list still has to keep out
2282
+ # the ordinary superlative about a person, which is what "the most honest
2283
+ # politician" is, so no human nouns go in. Nothing in either corpus
2284
+ # matches. "way to" gets its own branch, mirroring cleanest-x.
2285
+ pattern: /\bmost[ \t]+honest[ \t]+(?:(?!way\b)\w+[ \t]+){0,2}
2286
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|comparison
2287
+ |accounting|assessment|appraisal
2288
+ |reading|account|answer|version|summary|take|signal
2289
+ |through-line)\b
2290
+ |\bmost[ \t]+honest[ \t]+way[ \t]+to[ \t]+
2291
+ (?:say|put|frame|state|describe|phrase|think[ \t]+about)\b/ix,
2292
+ message: '"The most honest framing…" ranks your own claim for the reader.',
2293
+ suggestion: "Drop the ranking and make the claim; the reader grades it.",
2294
+ examples_bad: [
2295
+ "The most honest framing is that we guessed.",
2296
+ "That is the most honest way to put it.",
2297
+ "The most honest reading of the data is duller than the headline.",
2298
+ "This is the most honest summary anyone offered."
2299
+ ],
2300
+ examples_ok: [
2301
+ # The ordinary superlative, about a person.
2302
+ "He is the most honest person I know.",
2303
+ "She is the most honest politician in the state.",
2304
+ # "way to" outside the speech verbs.
2305
+ "This is the most honest way to earn a living."
2306
+ ],
2307
+ rationale: "Self-ranking: the writer tells the reader which of their own claims is the " \
2308
+ "candid one, which is the reader's job. The superlative also implies the rest " \
2309
+ "of the piece was less honest, and the sentence after it never says which part."
2310
+ ),
951
2311
  Rule.new(
952
2312
  id: "genuinely",
953
2313
  category: "rhetorical-tic",
@@ -977,6 +2337,72 @@ module Sloplint
977
2337
  "nominally free -- and nothing in the sentence marks which use is which, " \
978
2338
  "which is why this one runs only when you ask for it."
979
2339
  ),
2340
+ Rule.new(
2341
+ id: "actually-not-x",
2342
+ category: "rhetorical-tic",
2343
+ severity: "warning",
2344
+ # Two markers that each correct the reader, doubled up in one clause:
2345
+ # the adverb and the trailing "…, not X". Bare "actually" is not the
2346
+ # tell and is not matched, because "the build actually failed on the
2347
+ # second run" names which run and that is a fact with something behind
2348
+ # it to check.
2349
+ #
2350
+ # The narrowing is one structural constraint, not a word list: the
2351
+ # comma in front of "not" has to be the first comma of its clause and
2352
+ # has to follow a word. A writer correcting an assumption the reader
2353
+ # does hold names it first, and that setup is either a fronted clause
2354
+ # with a comma after it ("Despite the name, …") or a parenthetical
2355
+ # whose closing bracket the comma follows. No earlier comma means
2356
+ # nothing in the clause set the alternative up. A clause starts at the
2357
+ # beginning of the text, at a full stop, question mark, exclamation
2358
+ # mark, semicolon or colon, or at a line break.
2359
+ #
2360
+ # Nothing crosses a line break, so the rule cannot weld two rows of a
2361
+ # table or two items of a list into one correction. The deliberate
2362
+ # misses: a hard-wrapped correction, and a correction whose clause
2363
+ # carries an earlier comma of any kind, an apposition ("The report,
2364
+ # filed late, says the id is actually a byte string, not text.")
2365
+ # included. No pattern tells an apposition from a fronted setup, and
2366
+ # the rule would rather say nothing than guess.
2367
+ pattern: /(?:\A|(?<=[.;:!?\n]))[^.;:!?,\n]{0,200}\K
2368
+ \bactually\b[^.;:!?,\n]{0,120}(?<=[\p{Word}]),[ \t]*not\b/ix,
2369
+ message: '"Actually …, not X" corrects an assumption the text never offered.',
2370
+ suggestion: "State the fact plainly, or name the belief the correction answers.",
2371
+ examples_bad: [
2372
+ "The disclosure actually covered two incidents, not one.",
2373
+ "The identifier is actually a byte string, not text.",
2374
+ "It actually reads the file at startup, not on the first request.",
2375
+ # A colon opens a clause, so the correction after it is still the tell.
2376
+ "Note: the endpoint actually returns two fields, not one."
2377
+ ],
2378
+ examples_ok: [
2379
+ # The bare adverb, which names which run failed. Not matched.
2380
+ "The build actually failed on the second run.",
2381
+ # The setup sits in a fronted clause, so the correction answers
2382
+ # something the reader was given. The earlier comma drops both.
2383
+ "Despite the name, the identifier is actually a byte string, not text.",
2384
+ "Although the name suggests otherwise, it is actually a byte string, not text.",
2385
+ # The correction on its own, with no adverb in front of it.
2386
+ "The run that failed was the second, not the first.",
2387
+ # Negation in front of the adverb with no trailing correction. This
2388
+ # was the other candidate shape; every real instance of it carried
2389
+ # its own setup ("normally reserved but not actually registered"),
2390
+ # so it was rejected, and this fixture pins the rejection.
2391
+ "The provider was not actually billed for the usage.",
2392
+ # The correction's comma closes a parenthesis, and the assumption
2393
+ # being corrected is inside it.
2394
+ "The party actually sending the mail (which we assumed was the relay), not the sender.",
2395
+ # Two rows of a table, not a wrapped sentence. The rule never crosses
2396
+ # a line break, so it cannot join them.
2397
+ "Actually running processes\nThreads, not processes"
2398
+ ],
2399
+ rationale: "The adverb and the trailing \"not X\" both correct the reader, and a writer " \
2400
+ "needs one or the other, never both. Doubling them marks a contrast against " \
2401
+ "an alternative the reader was never offered: nothing said one incident, so " \
2402
+ "\"not one\" answers nobody. The setup can also sit in the sentence before, " \
2403
+ "which no pattern sees, so a flag on a correction that a previous sentence " \
2404
+ "genuinely set up is the known cost."
2405
+ ),
980
2406
  Rule.new(
981
2407
  id: "and-thats-fine",
982
2408
  category: "rhetorical-tic",
@@ -1002,6 +2428,111 @@ module Sloplint
1002
2428
  "was never raised. It reads as reassurance addressed to nobody, and the " \
1003
2429
  "paragraph is the same without it."
1004
2430
  ),
2431
+ Rule.new(
2432
+ id: "and-nothing-else",
2433
+ category: "rhetorical-tic",
2434
+ severity: "warning",
2435
+ # This rule is deliberately wide, and the cost is known. There is no verb
2436
+ # list and no imperative requirement, so the only narrowing is structural
2437
+ # -- which means the pattern cannot tell a leaked instruction from the
2438
+ # ordinary English construction it borrows. "Darkness there, and nothing
2439
+ # more!" matches, and so does any sentence built the same way. That is a
2440
+ # recall-over-precision choice, not an oversight; see rationale.
2441
+ #
2442
+ # Three structural guards, each pinned by an examples_ok fixture. The
2443
+ # tail must CLOSE the sentence, so "nothing more than a rumour" and
2444
+ # "nothing else could explain it" never match. A comma must introduce
2445
+ # it, which keeps "there was nothing more to say" out. And the bare
2446
+ # "no more" branch additionally requires "and": a large share of the
2447
+ # ordinary-prose hits are the amount sense ("two years, no more",
2448
+ # "no less, no more"), and requiring the conjunction drops them without
2449
+ # losing the closer the rule is after.
2450
+ #
2451
+ # A closing quote or bracket may sit between the tail and the period so
2452
+ # a quoted sentence still matches. "?" is not accepted as the terminal
2453
+ # mark -- "and nothing more?" asks a question, a different act. Token
2454
+ # gaps cross a hard-wrapped line but never a paragraph break.
2455
+ pattern: /,(?:[ \t]|\r?\n(?![ \t]*\r?\n))+
2456
+ (?:(?:and(?:[ \t]|\r?\n(?![ \t]*\r?\n))+)?
2457
+ nothing(?:[ \t]|\r?\n(?![ \t]*\r?\n))+(?:else|more|further)
2458
+ |and(?:[ \t]|\r?\n(?![ \t]*\r?\n))+no(?:[ \t]|\r?\n(?![ \t]*\r?\n))+more)
2459
+ [ \t]*(?=["'”’)\]]*[.!])/ix,
2460
+ message: "Trailing \"and nothing else\" restates what the sentence already said.",
2461
+ suggestion: "Cut the tail. If the exclusion is the point, say what was excluded.",
2462
+ examples_bad: [
2463
+ "Return the JSON, and nothing else.",
2464
+ "The endpoint gives back the id, and nothing more.",
2465
+ "Reply with the number, nothing else.",
2466
+ "Print the filename, nothing further.",
2467
+ "Hand over the receipt, and no more."
2468
+ ],
2469
+ examples_ok: [
2470
+ # The tail does not close the sentence.
2471
+ "It was nothing more than a rumour.",
2472
+ "The delay was down to the weather, and nothing else could explain it.",
2473
+ # No comma introduces it.
2474
+ "There was nothing more to say after that.",
2475
+ "Nothing else matters once the deadline passes.",
2476
+ # The amount sense, which is why bare "no more" needs the conjunction.
2477
+ "The lease runs two years, no more.",
2478
+ "He asked for a fair share, no more.",
2479
+ # A question is a different act.
2480
+ "Is there anything else, or nothing more?"
2481
+ ],
2482
+ rationale: "A model told to return one thing and nothing else carries the phrasing into " \
2483
+ "the prose it writes afterwards, where the exclusion adds nothing -- the " \
2484
+ "sentence already named what it covers. The catch is that English has always " \
2485
+ "used this tail, so the rule fires on careful writing too: twice in 1.92M " \
2486
+ "words of public-domain prose, once in 461k words of pre-2022 Hacker News, and " \
2487
+ "on every refrain in \"The Raven\". Expect to dismiss it on fiction and on " \
2488
+ "quoted verse."
2489
+ ),
2490
+ Rule.new(
2491
+ id: "nothing-else-frag",
2492
+ category: "rhetorical-tic",
2493
+ severity: "warning",
2494
+ # The same exclusion as its own sentence: "Return the JSON. Nothing
2495
+ # else." Built on the no-x-no-y-frag template -- the fragment must start
2496
+ # at a sentence boundary and the separator is at most two spaces or one
2497
+ # hard-wrapped newline, so a blanked code span under --markdown cannot
2498
+ # weld two distant sentences together, and a paragraph break stops it.
2499
+ #
2500
+ # Two guards past that. The fragment must open with a capital letter,
2501
+ # and the preceding mark cannot be a semicolon: both keep out the
2502
+ # continuation sense
2503
+ # ("keep pulling; nothing more"), which is one clause, not a closer.
2504
+ # And the fragment must be the whole sentence -- "Nothing else mattered."
2505
+ # has a verb and never matches. "No more." is left out entirely; as a
2506
+ # standalone sentence it is ordinary speech, and no-x-no-y-frag already
2507
+ # takes the chained form.
2508
+ pattern: /(?<=[.!?])(?:[ \t]{1,2}|\r?\n(?!\s*\n)[ \t]*)\K
2509
+ (?:And[ \t]+nothing|Nothing)[ \t]+(?:else|more|further)[ \t]*[.!]/x,
2510
+ message: "\"Nothing else.\" as a closer restates what the sentence already said.",
2511
+ suggestion: "Cut the fragment, or fold the exclusion into the sentence before it.",
2512
+ examples_bad: [
2513
+ "Return the JSON. Nothing else.",
2514
+ "Give me the number. Nothing more.",
2515
+ "Answer with the file name. And nothing else.",
2516
+ "Print the total. Nothing further."
2517
+ ],
2518
+ examples_ok: [
2519
+ # A verb follows, so the fragment is a sentence about something.
2520
+ "The tests passed. Nothing else mattered that afternoon.",
2521
+ # The continuation sense: one clause, joined by a semicolon.
2522
+ "Keep pulling; nothing more.",
2523
+ # A paragraph break is not a sentence separator.
2524
+ "The kiln runs hot.\n\nNothing more.",
2525
+ # Moby-Dick (public domain): the lower-case continuation the capital
2526
+ # letter guard is there to exclude.
2527
+ "and so on; nothing more."
2528
+ ],
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."
2535
+ ),
1005
2536
  # ── puffery ───────────────────────────────────────────────────────────
1006
2537
  Rule.new(
1007
2538
  id: "puffery-words",
@@ -1239,17 +2770,166 @@ module Sloplint
1239
2770
  "surface pattern can tell apart from the real thing. Hence info: a flag here " \
1240
2771
  "means 'this has the shape', not 'this is slop'."
1241
2772
  ),
2773
+ Rule.new(
2774
+ id: "isnt-x-its-y",
2775
+ category: "structure",
2776
+ severity: "info",
2777
+ # The corrective with the conjunction dropped: one clause rejects a
2778
+ # description, the next supplies the replacement through a second copula
2779
+ # ("It isn't the tool. It's the habit."). Every copula tense is covered,
2780
+ # and the two clauses may be joined by a period, semicolon, comma, or dash.
2781
+ #
2782
+ # The narrowing sits in the two complement slots, and it is lexical class
2783
+ # rather than vocabulary: neither slot may open with a pronoun, a
2784
+ # possessive, a preposition ("about" excepted, since "not about X, it's
2785
+ # about Y" is the same move), a degree word, or one of the predicate
2786
+ # adjectives that make the second clause a comment instead of a
2787
+ # replacement ("It isn't ready. It's close."). The article is matched
2788
+ # atomically so the guard cannot be skipped by backtracking past it. Those
2789
+ # guards cut a loose version from 19 hits to 4 over 889k words of
2790
+ # 19th-century fiction.
2791
+ #
2792
+ # What survives is the reason for info. An adjective outside the list
2793
+ # still slips through, and some hits are the same shape written by a
2794
+ # person -- "was not the wife; it was the children" is Conan Doyle. A flag
2795
+ # here says the sentence has the frame, not that a model wrote it.
2796
+ pattern: /(?:\b(?:is|are|was|were)\s+not
2797
+ |\b(?:is|are|was|were)n['’]t
2798
+ |\b(?:it|that|this|there)['’]s\s+not
2799
+ |\b(?:they|these|those|we|you)['’]re\s+not)
2800
+ (?:[ \t]|\r?\n(?!\s*\n))+
2801
+ (?>(?:a\s+|an\s+|the\s+)?)
2802
+ (?!so\b|just\b|only\b|merely\b|simply\b|solely\b|even\b|yet\b|quite\b|very\b
2803
+ |too\b|all\b|always\b|never\b|often\b|also\b|enough\b|really\b|actually\b
2804
+ |entirely\b|ready\b|close\b|done\b|easy\b|hard\b|clear\b|simple\b
2805
+ |possible\b|likely\b|true\b|false\b|fine\b|good\b|bad\b|better\b|worse\b
2806
+ |obvious\b|important\b|different\b|not\b|no\b|nothing\b|there\b|here\b
2807
+ |what\b|who\b|how\b|why\b|when\b|where\b|because\b|i\b|me\b|he\b|him\b
2808
+ |she\b|her\b|it\b|we\b|us\b|you\b|they\b|them\b|that\b|this\b|these\b
2809
+ |those\b|his\b|their\b|its\b|my\b|your\b|our\b|in\b|on\b|at\b|of\b|to\b
2810
+ |for\b|from\b|with\b|as\b|by\b|toward\b|towards\b|into\b|over\b|under\b
2811
+ |through\b|against\b|upon\b|within\b|without\b)
2812
+ [\w'’-]+
2813
+ [^.!?;\n]{0,40}?
2814
+ (?:[.!?;]|[ \t]*[—–]|,)
2815
+ (?:[ \t]|\r?\n(?!\s*\n))+
2816
+ (?:it|this|that|these|those|they)
2817
+ (?:['’]s|['’]re|\s+is|\s+are|\s+was|\s+were)
2818
+ (?:[ \t]|\r?\n(?!\s*\n))+
2819
+ (?>(?:a\s+|an\s+|the\s+)?)
2820
+ (?!so\b|just\b|only\b|merely\b|simply\b|solely\b|even\b|yet\b|quite\b|very\b
2821
+ |too\b|all\b|always\b|never\b|often\b|also\b|enough\b|really\b|actually\b
2822
+ |entirely\b|ready\b|close\b|done\b|easy\b|hard\b|clear\b|simple\b
2823
+ |possible\b|likely\b|true\b|false\b|fine\b|good\b|bad\b|better\b|worse\b
2824
+ |obvious\b|important\b|different\b|not\b|no\b|nothing\b|there\b|here\b
2825
+ |what\b|who\b|how\b|why\b|when\b|where\b|because\b|i\b|me\b|he\b|him\b
2826
+ |she\b|her\b|it\b|we\b|us\b|you\b|they\b|them\b|that\b|this\b|these\b
2827
+ |those\b|his\b|their\b|its\b|my\b|your\b|our\b|in\b|on\b|at\b|of\b|to\b
2828
+ |for\b|from\b|with\b|as\b|by\b|toward\b|towards\b|into\b|over\b|under\b
2829
+ |through\b|against\b|upon\b|within\b|without\b)
2830
+ [\w'’-]+/ix,
2831
+ message: '"isn\'t X, it\'s Y" corrects a description nobody offered.',
2832
+ suggestion: "State the second half on its own; drop the rejected one.",
2833
+ examples_bad: [
2834
+ "It isn't the tool. It's the habit.",
2835
+ "This wasn't a setback, it was a setup for the next release.",
2836
+ "The delay wasn't the network. It was the retry loop.",
2837
+ "Those aren't the metrics, they're the vanity numbers.",
2838
+ "This isn't failure. It's iteration.",
2839
+ "They're not customers; they are partners.",
2840
+ "These weren't accidents. They were choices."
2841
+ ],
2842
+ examples_ok: [
2843
+ # Predicate adjectives on both sides: a comment, not a replacement.
2844
+ "It isn't ready. It's close.",
2845
+ # Escalation word: that shape belongs to not-just-x-but-y.
2846
+ "It isn't only the cost. It is the delay too.",
2847
+ # The second clause needs a pronoun subject and a copula.
2848
+ "It wasn't the alarm that woke me. The dog did.",
2849
+ # The two clauses must be adjacent.
2850
+ "It isn't the heat. Everyone says so, and they have said so for years. It's the humidity.",
2851
+ # Prepositional complements are ordinary contrast.
2852
+ "The message was not to him; it was to the clerk.",
2853
+ # Pronoun complement.
2854
+ "It was not me who called; it was the neighbour.",
2855
+ # The guard holds after the article, which is matched atomically.
2856
+ "The fire was not out; it was the only light left.",
2857
+ # A paragraph break ends the frame.
2858
+ "The plan was not the problem.\n\nIt was the schedule."
2859
+ ],
2860
+ rationale: "The frame rejects a description nobody proposed, then supplies the true " \
2861
+ "one, so the sentence sounds like a correction while correcting nobody. It " \
2862
+ "is 'not A but B' with the conjunction dropped and the second half promoted " \
2863
+ "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."
2866
+ ),
2867
+ Rule.new(
2868
+ id: "not-by-x-but-by-y",
2869
+ category: "structure",
2870
+ severity: "info",
2871
+ # The corrective built on a repeated preposition: "not by A, but by B",
2872
+ # "not from A but from B". The copula rules above cannot see it because
2873
+ # nothing precedes "not" but the verb or a dash, so the anchor here is
2874
+ # the preposition itself, which must repeat after "but". That repetition
2875
+ # is the whole narrowing: a different preposition on the B side is a
2876
+ # concession or an afterthought, not a correction.
2877
+ #
2878
+ # A is capped at six words and B may not open with a pronoun. Neither
2879
+ # guard removes a false positive -- every corpus hit is the real shape
2880
+ # -- they only keep the match from running across a sentence.
2881
+ pattern: /\bnot(?:[ \t]|\r?\n(?!\s*\n))+
2882
+ (?<prep>by|for|from|with|about|in|on|at|to|of|through|because(?:[ \t]|\r?\n(?!\s*\n))+of|out(?:[ \t]|\r?\n(?!\s*\n))+of)
2883
+ (?:[ \t]|\r?\n(?!\s*\n))+
2884
+ (?:[\w'’-]+(?:[ \t]|\r?\n(?!\s*\n))+){0,5}[\w'’-]+
2885
+ ,?(?:[ \t]|\r?\n(?!\s*\n))+but(?:[ \t]|\r?\n(?!\s*\n))+
2886
+ (?:rather(?:[ \t]|\r?\n(?!\s*\n))+)?
2887
+ \k<prep>(?:[ \t]|\r?\n(?!\s*\n))+
2888
+ (?!that\b|this\b|it\b|him\b|her\b|them\b|me\b|us\b|you\b|which\b|whom\b|what\b)
2889
+ (?:a\s+|an\s+|the\s+)?[\w'’-]+/ix,
2890
+ message: '"not by A, but by B" is the corrective frame on a repeated preposition.',
2891
+ suggestion: "Say what it was by; drop the 'not by… but by' frame.",
2892
+ examples_bad: [
2893
+ "We won not by luck but by preparation.",
2894
+ "The gain came not from the model, but from the data.",
2895
+ "Judge it not on the demo but on the deployment.",
2896
+ "Revenue grew this quarter — not by a rounding error, but by a margin that survives any cut of the data."
2897
+ ],
2898
+ examples_ok: [
2899
+ # The preposition changes: a contrast, not a correction.
2900
+ "He came not for the money but with an apology.",
2901
+ # B-side pronoun.
2902
+ "She wrote not to him but to them.",
2903
+ # A capped at six words.
2904
+ "The deal closed not in the long slow grind of the seventh week, but in an afternoon.",
2905
+ # "not only" belongs to the escalation rules, and the B preposition here would not repeat anyway.
2906
+ "It was not only for the money.",
2907
+ # A paragraph break ends the frame.
2908
+ "We won not by luck\n\nBut by then it hardly mattered."
2909
+ ],
2910
+ 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."
2915
+ ),
1242
2916
  Rule.new(
1243
2917
  id: "rule-of-three",
1244
2918
  category: "structure",
1245
2919
  severity: "info",
1246
2920
  default_on: false,
1247
2921
  pattern: /\b[\w'-]+,\s+[\w'-]+,\s+(?:and\s+)?[\w'-]+[.!?]/,
1248
- message: "Three parallel comma items closing a sentence (heuristic; high false-positive).",
2922
+ message: "Three single words in a comma series closing a sentence (heuristic; high false-positive).",
1249
2923
  suggestion: "Fine in moderation; watch for the AI habit of ending on triplets.",
1250
2924
  examples_bad: ["It was fast, cheap, and simple."],
1251
- examples_ok: ["We met on Tuesday afternoon."],
1252
- rationale: "Rule-of-three endings are a model habit, but humans use them too — off by default."
2925
+ examples_ok: [
2926
+ "We met on Tuesday afternoon.",
2927
+ # Phrasal items do not match; the closing two slots take one word each.
2928
+ "It was very fast, very cheap, and very simple."
2929
+ ],
2930
+ rationale: "Rule-of-three endings are a model habit, but humans use them too — off by default. " \
2931
+ "The closing two items must be single words; a triad of phrases is three ordinary " \
2932
+ "list items to a regex, so those are left unflagged rather than guessed at."
1253
2933
  ),
1254
2934
  # clause-triad-then was cut. The pattern (comma-clause, comma-clause,
1255
2935
  # "then" clause) had no way to require the clauses actually be parallel
@@ -1261,6 +2941,485 @@ module Sloplint
1261
2941
  # then...", elliptical legal "shall... then..."). It was also the most
1262
2942
  # expensive rule in the catalog at 65% of scan time on 1MB. Per
1263
2943
  # CLAUDE.md: some tells can't be regexes; this was one.
2944
+ Rule.new(
2945
+ id: "everyone-nobody",
2946
+ category: "structure",
2947
+ severity: "warning",
2948
+ # The comma-spliced antithesis on quantifier subjects: "Everyone wants
2949
+ # the dashboard, nobody maintains it." One clause opens on
2950
+ # everyone/everybody, the other on nobody/no one/none or "one N"
2951
+ # ("everyone may pitch, one editor decides"), joined by a bare comma, and
2952
+ # the second clause closes the sentence. The comma splice is the
2953
+ # evidence: with "and" or "but" it is an ordinary sentence, and with a
2954
+ # period it is two. The first subject must open a clause (one or two
2955
+ # spaces after a stop, as elsewhere in the catalog), the two subjects
2956
+ # must differ in polarity, so the anaphoric "nobody is on my side,
2957
+ # nobody takes part with me" is not a hinge, and each clause is at most
2958
+ # eighty characters with no newline, so a hinge never crosses a
2959
+ # paragraph break and the comma gap is at most two spaces.
2960
+ #
2961
+ # The "one N" and "none" subjects need guards, because the comma slot
2962
+ # also holds phrases that are not clauses. "one of them", "one by one",
2963
+ # "one per ticket", and a measure ("one hour before the talk") are out;
2964
+ # so is a capitalised "One" mid-sentence, a proper noun, which the
2965
+ # case-sensitive lookahead catches under /i. "none of which" is a
2966
+ # relative clause and "none louder than" a comparative, and both are
2967
+ # out.
2968
+ pattern: /(?:^|(?<=[.!?:;])[ \t]{1,2})\K
2969
+ (?:(?:everyone|everybody)[ \t]+[^,.;:!?\n]{2,80},[ \t]{1,2}
2970
+ (?:nobody|no[ \t]+one|none(?![ \t]+(?:of|more|less|so)\b|[ \t]+\w+er\b)
2971
+ |one[ \t]+(?!of\b|by\b|per\b|(?:hour|minute|second|day|week|month|year|dollar|cent|mile|foot|inch|pound|kilo|metre|meter)s?\b)(?-i:(?=[a-z]))[\w'’-]+)
2972
+ |(?:nobody|no[ \t]+one)[ \t]+[^,.;:!?\n]{2,80},[ \t]{1,2}
2973
+ (?:everyone|everybody|one[ \t]+(?!of\b|by\b|per\b|(?:hour|minute|second|day|week|month|year|dollar|cent|mile|foot|inch|pound|kilo|metre|meter)s?\b)(?-i:(?=[a-z]))[\w'’-]+))
2974
+ [ \t]+[^,.;:!?\n]{2,80}[.;!?]/ix,
2975
+ message: '"Everyone X, nobody Y." is the AI antithesis hinge.',
2976
+ suggestion: "Say which one is the problem, in its own sentence.",
2977
+ examples_bad: [
2978
+ "Everyone wants the dashboard, nobody maintains it.",
2979
+ "Run it like a newsroom desk: everyone may pitch, one editor decides.",
2980
+ "Nobody owns the file, everyone edits it.",
2981
+ "Everyone wanted the job, none applied.",
2982
+ # Two spaces after the stop still open a clause.
2983
+ "Ship it. Everyone wants the dashboard, nobody maintains it.",
2984
+ # Clauses may run to eighty characters.
2985
+ "Everyone talks about observability in the abstract, nobody wants to own the pager rotation."
2986
+ ],
2987
+ examples_ok: [
2988
+ # A conjunction makes it a sentence, not a hinge.
2989
+ "Everyone left early, and nobody noticed.",
2990
+ # Pride and Prejudice (Austen, public domain): the same subject twice is
2991
+ # anaphora, not antithesis.
2992
+ "Nobody is on my side, nobody takes part with me;",
2993
+ # The second clause must close the sentence.
2994
+ "Everyone who came, nobody excepted, signed the book.",
2995
+ # A period is two sentences.
2996
+ "Everyone wants the dashboard. Nobody maintains it.",
2997
+ # "one of", "one by one", "one per", a measure, and a proper noun are
2998
+ # not a second subject.
2999
+ "Everyone signed up, one of them dropped out later.",
3000
+ "Nobody moved for a moment, one by one they stood up.",
3001
+ "Everybody got a ticket, one per person at the gate.",
3002
+ "Everyone arrived by noon, one hour before the talk.",
3003
+ "Nobody stirred in the hall, One Direction played on the radio.",
3004
+ # A relative clause and a comparative are not a second subject.
3005
+ "Everyone brought a dish, none of which we actually ate.",
3006
+ "Everyone in the room laughed, none louder than the author himself.",
3007
+ # A clause over eighty characters is a sentence of its own.
3008
+ "Everyone who has ever tried to keep a dashboard alive through two reorganisations and a migration knows the cost, nobody maintains it.",
3009
+ # A paragraph break is not a comma.
3010
+ "Everyone wants the dashboard,\n\nnobody maintains it."
3011
+ ],
3012
+ rationale: "The everyone/nobody hinge states a whole diagnosis as a balanced pair of " \
3013
+ "clauses, and the balance is what makes it sound settled. Models reach for " \
3014
+ "it to close a setup; careful writers join the clauses with a conjunction " \
3015
+ "or give the problem its own sentence."
3016
+ ),
3017
+ Rule.new(
3018
+ id: "np-fragment-and",
3019
+ category: "structure",
3020
+ severity: "info",
3021
+ # A whole sentence that is two noun phrases and an "and": "A named owner
3022
+ # and a quarterly review." It is the fix half of a model's
3023
+ # problem-then-fix pair, with the verb left for the reader to supply.
3024
+ # The sentence must open on A/An/One at a sentence start or after a
3025
+ # list marker (the pair's usual habitat is a bulleted list), the second
3026
+ # phrase must open on a/an/one, each phrase is one to three words, and
3027
+ # nothing else may be in the sentence. No auxiliary or modal may appear
3028
+ # anywhere in it, contractions included, so "A man and a woman were
3029
+ # there." and "A man and a woman aren't here." never match.
3030
+ #
3031
+ # Ships at info, and this is why: a lexical verb is invisible to the
3032
+ # pattern, so "A car and a truck collided." has the same shape and
3033
+ # flags. The corpora say that sentence is rare (one hit in 1.25M words
3034
+ # of public-domain prose, most of it narrative), but it is a complete
3035
+ # sentence, and nothing a regex can see separates it from the fragment.
3036
+ pattern: /(?:^|(?<=[.!?])[ \t]{1,2})(?:[-*+•][ \t]+|\d+[.)][ \t]+)?\K(?:A|An|One)(?:[ \t]|\r?\n(?!\s*\n))+
3037
+ (?:(?!(?:(?:is|are|was|were|be|been|being|am|has|have|had|having|does|do|did|can|could|will|would|shall|should|must|may|might|ought|ain)(?![\w'’-])|\w+n['’]t(?![\w'’-])))[\w'’-]+(?:[ \t]|\r?\n(?!\s*\n))+){0,2}(?!(?:(?:is|are|was|were|be|been|being|am|has|have|had|having|does|do|did|can|could|will|would|shall|should|must|may|might|ought|ain)(?![\w'’-])|\w+n['’]t(?![\w'’-])))[\w'’-]+,?(?:[ \t]|\r?\n(?!\s*\n))+and(?:[ \t]|\r?\n(?!\s*\n))+(?:a|an|one)(?:[ \t]|\r?\n(?!\s*\n))+
3038
+ (?:(?!(?:(?:is|are|was|were|be|been|being|am|has|have|had|having|does|do|did|can|could|will|would|shall|should|must|may|might|ought|ain)(?![\w'’-])|\w+n['’]t(?![\w'’-])))[\w'’-]+(?:(?:[ \t]|\r?\n(?!\s*\n))+|(?=\.))){1,3}\.(?=\s|\z)/x,
3039
+ message: '"A X and a Y." as a whole sentence is an AI fragment.',
3040
+ suggestion: "Give the sentence a verb, or fold it into the one before.",
3041
+ examples_bad: [
3042
+ "Nobody checked it after launch. A named owner and a quarterly review.",
3043
+ "One merger and a version-controlled folder.",
3044
+ "An owner and a deadline.",
3045
+ # The pair's usual habitat.
3046
+ "- A named owner and a quarterly review.",
3047
+ "1. A named owner and a quarterly review.",
3048
+ # A comma before "and" is still the pair.
3049
+ "A named owner, and a quarterly review."
3050
+ ],
3051
+ examples_ok: [
3052
+ "A man and a woman were waiting at the door.",
3053
+ "A dog and a cat can share a house.",
3054
+ # Contractions and the rarer auxiliaries are auxiliaries too.
3055
+ "A man and a woman aren't here.",
3056
+ "A boy and a girl shall meet.",
3057
+ "A boy and a girl ought to know.",
3058
+ # Not at a sentence start.
3059
+ "We hired a designer and an engineer.",
3060
+ # More than three words on a side is a clause, not a label.
3061
+ "A long walk down to the river and a swim before breakfast.",
3062
+ # Only "and" between two phrases; a list is not the pair.
3063
+ "A hammer, a saw, and a level."
3064
+ ],
3065
+ rationale: "Two noun phrases and an 'and', standing as a sentence, is how a model " \
3066
+ "hands over a fix without committing to a verb: the reader supplies " \
3067
+ "'you need' or 'add'. A short sentence with a plain verb ('A car and a " \
3068
+ "truck collided.') has the same shape and cannot be told apart, which is " \
3069
+ "why this is a question rather than a verdict; a draft full of them, " \
3070
+ "especially as list items, should be read as a warning."
3071
+ ),
3072
+ Rule.new(
3073
+ id: "quip-question",
3074
+ category: "structure",
3075
+ severity: "info",
3076
+ # The verbless question that opens a pitch: "No invite?", "New to the
3077
+ # tool?", "Still stuck?", "Ready to start?". It must start a sentence,
3078
+ # open on one of a short list of words, run one to four more words, and
3079
+ # end on the question mark, with no auxiliary or contraction anywhere,
3080
+ # so a real question ("Not what you expected?" flags, "Is it new?" does
3081
+ # not) stays out. "Need" and "Want" are not on the list: "Need help?"
3082
+ # is a question with its verb elided, not a verbless one. Gaps may
3083
+ # cross a hard-wrapped newline but never a paragraph break. Ships at
3084
+ # info: dialogue and forum replies ask the same shape of a person, and
3085
+ # one is a question, not a verdict.
3086
+ pattern: /(?:^|(?<=[.!?])[ \t]{1,2})\K(?:No|New|Still|Not|Already|Ready|Curious|Unsure|Stuck|Tired|Confused|Worried)(?:[ \t]|\r?\n(?!\s*\n))+
3087
+ (?:(?!(?:is|are|was|were|has|have|had|do|does|did|can|could|will|would|should|must|may|might|am|[\w]+n['’]t)\b)[\w'’-]+(?:[ \t]|\r?\n(?!\s*\n))+){0,3}(?!(?:is|are|was|were|has|have|had|do|does|did|can|could|will|would|should|must|may|might|am|[\w]+n['’]t)\b)[\w'’-]+[ \t]*\?/x,
3088
+ message: "A verbless opening question is a marketing-copy tell.",
3089
+ suggestion: "Ask it as a sentence, or state what follows without the setup.",
3090
+ examples_bad: [
3091
+ "No invite? Start a team of your own.",
3092
+ "New to the tool? Read the guide first.",
3093
+ "Still stuck after that? Ask in the channel.",
3094
+ "Ready to start?",
3095
+ "Not what you expected?",
3096
+ # A hard-wrapped quip survives one newline.
3097
+ "Still\nstuck? Ask in the channel."
3098
+ ],
3099
+ examples_ok: [
3100
+ "Is it new?",
3101
+ "No, they aren't?",
3102
+ "Not sure if it will work?",
3103
+ # Not at a sentence start.
3104
+ "She asked, Still unsure?",
3105
+ # An elided verb is a question, not a quip.
3106
+ "Need help?",
3107
+ "Want the short version?",
3108
+ # Too long to be a quip.
3109
+ "Still waiting for the last batch of reviews from the other team?",
3110
+ # A paragraph break ends it, and the question mark stays on the line.
3111
+ "Not sure\n\nwhat happened here?",
3112
+ "Not sure what happened\n?"
3113
+ ],
3114
+ rationale: "A one-line question with no verb is the hook of a landing page, and a " \
3115
+ "model reaches for it to open any section. People ask the same shape in " \
3116
+ "replies, of a product or a person, so one flag is a question; several " \
3117
+ "in a draft should be read as a warning."
3118
+ ),
3119
+ Rule.new(
3120
+ id: "mic-drop-closer",
3121
+ category: "structure",
3122
+ severity: "info",
3123
+ # The kicker: a sentence of at least sixty characters, then a closer of
3124
+ # two to eight words that ends the paragraph and opens on a quantifier
3125
+ # ("Nothing here needs a new login.", "Most teams end up
3126
+ # with two.", "Then find out whether it paid off."). The long sentence
3127
+ # must start a sentence itself, so the scan is linear, and \K drops it
3128
+ # from the match so the note points at the closer. The closer must be
3129
+ # the last thing in the paragraph: a blank line or the end of the text
3130
+ # must follow, so the same sentence mid-paragraph, or a bullet followed
3131
+ # by another bullet, is just a sentence. Both sentences may cross a
3132
+ # hard-wrapped newline. Whitespace inside the long sentence is capped at
3133
+ # two spaces a run, so a URL or code span blanked by --markdown cannot
3134
+ # manufacture the sixty characters. The gap between the two sentences
3135
+ # is one or two spaces, and the closer needs at least two words.
3136
+ #
3137
+ # "That", "This" and "It" were in the opener list and are out. They are
3138
+ # not quantifiers, and procedural writing ends a step with one as a
3139
+ # matter of course: every hit in 2.25M words of engineering prose was a
3140
+ # bare demonstrative closing a paragraph after a long sentence -- "This
3141
+ # completes the roughing operations.", "This is the normal running
3142
+ # position." The quantifiers carry the tell; the demonstratives are
3143
+ # ordinary, and no examples_bad used one.
3144
+ #
3145
+ # Ships at info, and the rationale says why: people end paragraphs this
3146
+ # way too, at about 150 per million words on Hacker News. One is
3147
+ # nothing. A draft where most paragraphs end this way is the tell, and
3148
+ # an agent that sees the flag repeat should read the family as a
3149
+ # warning.
3150
+ # The long-sentence prefix is wrapped in an atomic group. Its branches
3151
+ # cannot match [.!?], so the greedy run always ends at the first
3152
+ # sentence-ending punctuation or paragraph break and no shorter partition
3153
+ # can ever satisfy the [.!?] that follows -- backtracking into it only
3154
+ # ever fails. Without (?>...) it fails slowly: PDF-extracted prose with
3155
+ # long unpunctuated stretches (the Columbia report has an 864-character
3156
+ # one) sent this into catastrophic backtracking, 62 seconds for a 2 KB
3157
+ # window and no completion on the 1.1 MB document.
3158
+ pattern: /#{SENTENCE_OF_SIXTY_CHARACTERS_ENDING_IN_PUNCTUATION_AND_SPACE}\K
3159
+ (?:Nothing|Most|None|Everything|Everyone|Nobody|Then|Neither|Both)
3160
+ (?:,?(?:[ \t]|\r?\n(?!\s*\n))+[\w'’-]+){1,7}[.!?](?=[ \t]*(?:\r?\n[ \t]*(?:\r?\n|\z)|\z))/x,
3161
+ message: "A short quantifier-led closer after a long sentence is the AI kicker.",
3162
+ suggestion: "Cut the closer, or move the claim to the front of the paragraph.",
3163
+ examples_bad: [
3164
+ "Each step can be done in the app, pasted into whichever assistant the company allows, or run the way the team already works. Nothing here needs a new login.",
3165
+ "Keep a private notebook for the drafts where taste matters, and a shared one for the work that passes between desks. Most teams end up with two.",
3166
+ "Ship the shared notebook to a team that has agreed on the owner, the folder, and the first task it will hold. Then find out whether it paid off.\n\nNext week: the audit.",
3167
+ # Both sentences may be hard-wrapped.
3168
+ "Each step can be done in the app, pasted into whichever\nassistant the company allows, or run the way the team\nalready works. Nothing here\nneeds a new login.\n"
3169
+ ],
3170
+ examples_ok: [
3171
+ # No long sentence before it.
3172
+ "Nothing here needs a new login.",
3173
+ # Not the end of the paragraph, wrapped or not.
3174
+ "Each step can be done in the app, pasted into whichever assistant the company allows, or run the way the team already works. Nothing here needs a new login. The prompts are in the appendix.",
3175
+ "Each step can be done in the app, pasted into whichever assistant the company allows, or run the way the team already works. Nothing here needs a new login.\nThe prompts are in the appendix.",
3176
+ # A closer that does not open on the list.
3177
+ "Each step can be done in the app, pasted into whichever assistant the company allows, or run the way the team already works. We kept the prompts short.",
3178
+ # Too long to be a kicker.
3179
+ "Each step can be done in the app, pasted into whichever assistant the company allows, or run the way the team already works. Most of the teams we spoke to ended up using a mix of two of them.",
3180
+ # Too short to be a kicker.
3181
+ "Each step can be done in the app, pasted into whichever assistant the company allows, or run the way the team already works. Nothing.",
3182
+ # More than two spaces is not a sentence gap.
3183
+ "Each step can be done in the app, pasted into whichever assistant the company allows, or run the way the team already works. Nothing here needs a new login.",
3184
+ # A bullet followed by another bullet is not a paragraph end.
3185
+ "- Each step can be done in the app, pasted into whichever assistant the company allows, or run the way the team already works. Nothing here breaks.\n- We moved the deploy script into the repo so the on-call rota could run it. Nothing here breaks.\n- The rest is in the appendix and needs no change from anyone on the team.\n",
3186
+ # Blanked text cannot make the long sentence.
3187
+ "See here. Nothing here breaks.\n",
3188
+ # A bare demonstrative closing a step is how procedural writing ends
3189
+ # a paragraph. Wording follows Turning and Boring (1919) and
3190
+ # Aviation Engines (1917), both public domain by date.
3191
+ "The cutting tools are set to the dimensions required for the finished work, and the stops are locked. This completes the roughing operations.",
3192
+ "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."
3193
+ ],
3194
+ rationale: "A short sentence after a long one borrows emphasis from the contrast, " \
3195
+ "and a model spends that emphasis at the end of nearly every paragraph, " \
3196
+ "restating the point it has just made. People write the shape too, at " \
3197
+ "about 150 per million words, so one flag means nothing; a draft " \
3198
+ "where the flag repeats paragraph after paragraph should be read as a " \
3199
+ "warning, and the fix is usually to delete the closer outright."
3200
+ ),
3201
+ Rule.new(
3202
+ id: "ellipsis-closer",
3203
+ category: "structure",
3204
+ severity: "info",
3205
+ # The same long-sentence-then-short-closer shape as mic-drop-closer,
3206
+ # but the tell lives in the verb, not the subject. mic-drop-closer's
3207
+ # closer opens on a quantifier and keeps a full verb with its object
3208
+ # ("Nothing here needs a new login."). This one carries no verb at
3209
+ # all: the closer's verb phrase has been elided down to the bare
3210
+ # auxiliary that would have introduced it, and the object it
3211
+ # promised never arrives -- "They found an exposed dashboard and
3212
+ # asked the agent running on it to hand over its own key. The agent
3213
+ # did." Because the tell is the missing verb, this rule needs no
3214
+ # subject list the way mic-drop-closer does, and "That", "This" and
3215
+ # "It" are not excluded here the way they are there: "This completes
3216
+ # the roughing operations." ends on a full verb with an object and
3217
+ # never matches (there is no auxiliary at the sentence's end), while
3218
+ # "This did." would, on the same subject mic-drop-closer had to bar.
3219
+ #
3220
+ # The closer is a one-to-three word subject running straight into the
3221
+ # bare auxiliary and a period, and, same as mic-drop-closer, it must
3222
+ # be the last thing in the paragraph -- a blank line or the end of
3223
+ # the text follows it. A closing quotation mark after the period is
3224
+ # not blank, so quoted dialogue never satisfies this and is excluded
3225
+ # the same way short-run excludes it. A negative lookahead drops any
3226
+ # closer that still holds "what", "that", "which", "who", "why" or
3227
+ # "how", because those mark a subordinate clause supplying its own
3228
+ # complement rather than an elided one: "Nobody knew who did." asks
3229
+ # "who did it", which the subject cap alone does not catch, since
3230
+ # "Nobody knew who" is itself only three words.
3231
+ #
3232
+ # Reuses mic-drop-closer's long-sentence prefix rather than pasting a
3233
+ # second copy of it; see that constant's comment for the atomic group
3234
+ # that keeps the scan linear.
3235
+ #
3236
+ # The negated form is bolted on with "n't" for every auxiliary except
3237
+ # two irregular ones: "can" already ends in n, so its negation is
3238
+ # "can't", not "cann't", and "will" negates to the different stem
3239
+ # "won't" rather than "willn't". Both are spelled out rather than
3240
+ # built by suffix, so the two most common contractions in the list
3241
+ # are not silently unmatchable.
3242
+ pattern: /#{SENTENCE_OF_SIXTY_CHARACTERS_ENDING_IN_PUNCTUATION_AND_SPACE}\K
3243
+ (?![^.!?\n]*\b(?:what|that|which|who|why|how)\b)
3244
+ [A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[ \t]+
3245
+ (?:(?:did|does|do|was|were|is|are|had|has|could|would|should|might|must)(?:n['’]t)?
3246
+ |can(?:['’]t)?|will|won['’]t)\.
3247
+ (?=[ \t]*(?:\r?\n[ \t]*(?:\r?\n|\z)|\z))/x,
3248
+ message: "A closer that ends on a bare auxiliary is the AI verb-phrase-ellipsis kicker.",
3249
+ suggestion: "Cut the closer, or say what actually happened.",
3250
+ examples_bad: [
3251
+ "They found an exposed dashboard and asked the agent running on it to hand over its own key. The agent did.",
3252
+ "The team spent three weeks arguing about whether the migration was worth the downtime it would cost the on-call rotation. It wasn't.",
3253
+ "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.",
3254
+ "He asked whether the on-call engineer had actually paged the second responder before escalating past the fifteen-minute window. She had.",
3255
+ # The two irregular negated forms, pinned so a later rewrite of the
3256
+ # suffix can't quietly drop them again.
3257
+ "They double-checked whether the fallback path could still serve read traffic once the primary region failed over during the drill. It can't.",
3258
+ "The team hoped the migration window would close before the seasonal freight peak began overwhelming the warehouse systems. It won't."
3259
+ ],
3260
+ examples_ok: [
3261
+ # Not the end of the paragraph.
3262
+ "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.",
3263
+ # A subordinate clause, not an elided one -- caught by the wh-word guard.
3264
+ "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.",
3265
+ # No long sentence in front of it.
3266
+ "The agent did.",
3267
+ # A subject longer than three words -- the cap, not the wh-guard, excludes it.
3268
+ "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.",
3269
+ # A full verb with an object, not a bare auxiliary.
3270
+ "Each step already passed local review before it reached the pipeline that runs on every push to the shared branch. It worked.",
3271
+ # A bare demonstrative closing a step is how procedural writing ends
3272
+ # a paragraph, and it ends on a full verb, not an auxiliary. Wording
3273
+ # follows Turning and Boring (1919) and Aviation Engines (1917),
3274
+ # both public domain by date; mic-drop-closer carries the same two
3275
+ # examples for the same reason.
3276
+ "The cutting tools are set to the dimensions required for the finished work, and the stops are locked. This completes the roughing operations.",
3277
+ "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."
3278
+ ],
3279
+ rationale: "The closer withholds exactly the thing the long setup built toward -- the " \
3280
+ "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."
3284
+ ),
3285
+ Rule.new(
3286
+ id: "short-run",
3287
+ category: "structure",
3288
+ severity: "info",
3289
+ # Three consecutive sentences of thirty characters or fewer, each
3290
+ # opening on a letter and closing on a full stop, with no quotation
3291
+ # mark or digit in any of them: "Nobody used it. A named owner. Then a
3292
+ # review." The run must start at a real boundary (the text, a blank
3293
+ # line, a line ending on a stop, or a stop and one or two spaces), so
3294
+ # the short tail of a hard-wrapped long sentence never opens one; it
3295
+ # may cross a hard-wrapped newline but not a paragraph break, and the
3296
+ # indent after a newline is at most four spaces, so a URL or code span
3297
+ # blanked by --markdown cannot weld two sentences. A list marker may
3298
+ # open the run, but consecutive bullets are a list, not staccato.
3299
+ #
3300
+ # Dialogue is excluded by the boundary: a closing quote after the stop
3301
+ # is not a space. A quotation mark inside a sentence is excluded by the
3302
+ # class. A sentence with a digit in it is data, and a run holding a
3303
+ # multi-letter abbreviation ("Prof.", "Dept.") is dropped, since the
3304
+ # abbreviation is not a sentence end.
3305
+ #
3306
+ # Three narrowings after 2.25M words of technical prose, all of them the
3307
+ # same mistake: reading document furniture as sentences.
3308
+ #
3309
+ # A "sentence" ending on a lone letter is a list label, not a sentence.
3310
+ # The guard used to cover capitals only, for initials ("Alan W."), so
3311
+ # lettered enumeration walked through it -- "Acronym e. OpNom f. Hazard
3312
+ # System record number g." is one form field, read as three sentences.
3313
+ # No English sentence ends on a bare letter, either case, so the guard
3314
+ # now covers both.
3315
+ #
3316
+ # Every sentence in the run must hold a space. A one-word "sentence" is
3317
+ # an abbreviation, and a citation line is nothing else: "Natl. Inst.
3318
+ # Stand. Technol." was a clean three-sentence run under the old shape.
3319
+ #
3320
+ # Every sentence opens on a capital. Lowercase-initial runs are speech
3321
+ # transcribed, not prose written: a cockpit voice recorder transcript
3322
+ # ("we all get gas. we go to divert to Albany. we pick up a load.") has
3323
+ # the staccato shape exactly, and an accident report carries pages of
3324
+ # it. A sentence in edited prose starts with a capital.
3325
+ #
3326
+ # And the run may not step over a list marker -- see MARKER below. Questions and exclamations are
3327
+ # left out because a run of them is a different device.
3328
+ #
3329
+ # Ships at info. A staccato run is a device people use on purpose, at
3330
+ # about thirty per million words on Hacker News, so one flag is a
3331
+ # question. A draft that keeps doing it is the tell, and an agent that
3332
+ # sees the flag repeat should read the family as a warning.
3333
+ # MARKER is the list furniture this rule has to see in order to ignore
3334
+ # it: a bullet glyph, the literal "o" that plain-text technical
3335
+ # documents use as one, a numbered item, and a lettered item. It opens a
3336
+ # run (a bullet may hold staccato) but may never sit *inside* one --
3337
+ # three bullets in a row are a list, which is what the comment above
3338
+ # always intended and the pattern did not enforce.
3339
+ pattern: /(?:\A|(?<=\n\n)|(?<=[.!?])[ \t]{0,2}\r?\n|(?<=[.!?])[ \t]{1,2})[ \t]{0,4}
3340
+ (?:[-*+•][ \t]+|o[ \t]+(?=[A-Z])|\d+[.)][ \t]+|[A-Za-z][.)][ \t]+(?=[A-Z]))?\K
3341
+ (?:[A-Za-z][^.!?\n"“”0-9]{3,29}(?<![^A-Za-z'’][A-Za-z])\.
3342
+ (?:[ \t]{1,2}|\r?\n(?!\s*\n)[ \t]{0,4})
3343
+ (?![-*+•][ \t]|o[ \t]+[A-Z]|\d+[.)][ \t]|[A-Za-z][.)][ \t]+[A-Z])){2}
3344
+ [A-Za-z][^.!?\n"“”0-9]{3,29}(?<![^A-Za-z'’][A-Za-z])\.(?=\s|\z)/x,
3345
+ message: "A run of three short sentences reads as AI staccato.",
3346
+ suggestion: "Join two of them, or give one of them a subordinate clause.",
3347
+ # Two whole-run exclusions, because each is a property of the run and
3348
+ # not of any one sentence in it. Three single-word "sentences" in a row
3349
+ # is a citation line ("Natl. Inst. Stand. Technol."), never staccato --
3350
+ # one single-word sentence is the archetypal kicker and stays. Three
3351
+ # lowercase-led sentences in a row is transcribed speech ("we all get
3352
+ # gas. we go to divert to Albany."); a run that reaches a capital
3353
+ # anywhere is prose, so identifier-initial writing ("npm was slow. git
3354
+ # blame helped. We moved on.") is untouched.
3355
+ skip: [
3356
+ /\b(?:Corp|Prof|Dept|Sept|Univ|Assn|approx|misc|cont|Ave|Blvd|Fig|Est|Inc|Ltd|vol|etc|Mrs|Mr|Ms|Dr|St|Jr|Sr|No)\./,
3357
+ /\A\S+\.[ \t\r\n]+\S+\.[ \t\r\n]+\S+\.\z/,
3358
+ /\A[a-z][^.!?]*\.\s+[a-z][^.!?]*\.\s+[a-z][^.!?]*\.\z/
3359
+ ],
3360
+ examples_bad: [
3361
+ "Nobody used it. A named owner. Then a review.",
3362
+ "The draft sat on one desk. Nobody else saw it. So it never shipped.",
3363
+ "Drafts only at first. Widen after a month. Two people sign off.",
3364
+ # A hard-wrapped run survives one newline.
3365
+ "Nobody used it. A named owner.\nThen a review.",
3366
+ # A bullet may hold a run.
3367
+ "- Nobody used it. A named owner. Then a review.",
3368
+ # An acronym ends a sentence; only a lone initial does not.
3369
+ "Nobody used it. We shipped it to QA. Then a review.",
3370
+ # A one-word sentence is the kicker, not an abbreviation, so only a
3371
+ # run made entirely of them is excluded.
3372
+ "The fix landed. Agreed. We moved on.",
3373
+ # A sentence may end on a possessive; that is not a bare letter.
3374
+ "We shipped a fix. The bug was Ana's. Nobody cared.",
3375
+ # Identifier-initial prose is the register this tool is aimed at, so a
3376
+ # run only counts as transcript when nothing in it reaches a capital.
3377
+ "git blame helped. npm was slow. We moved on."
3378
+ ],
3379
+ examples_ok: [
3380
+ "Nobody used it. A named owner and a quarterly review that the whole team can see.",
3381
+ # Dialogue.
3382
+ "\"Go now.\" \"I will.\" \"Then go.\"",
3383
+ # A quotation mark inside a sentence.
3384
+ "He said \"go\" today. Then a review. So it ended.",
3385
+ # A paragraph break ends the run.
3386
+ "Nobody used it. A named owner.\n\nThen a review.",
3387
+ # Questions and exclamations are a different device.
3388
+ "Who owns it? Nobody. Who checks it? Nobody.",
3389
+ # Two short sentences are a pair.
3390
+ "Ship it. Then find out if it was worth the effort and the wait.",
3391
+ # One sentence over thirty characters breaks the run.
3392
+ "Nobody used it. A named owner reviewed the draft again. Then a review.",
3393
+ # Numbers are data, and an initial is not a sentence end.
3394
+ "Hold cash. Expected value: 1000. Expected tax: none.",
3395
+ "Alan W. Prosser loves officer Kane. Nobody else does.",
3396
+ # An abbreviation is not a sentence end.
3397
+ "She wrote to Prof. Ellis at the Dept. Nobody replied to her.",
3398
+ # The tail of a hard-wrapped long sentence does not open a run.
3399
+ "The build had been red since Tuesday and nobody could say quite why, so we\nbisected it. Then we found the flake. It was a clock skew.",
3400
+ # Blanked text after a newline cannot weld two sentences.
3401
+ "Nobody used it. A named owner.\n Then a review.",
3402
+ # Consecutive bullets are a list.
3403
+ "- Fast setup.\n- No config.\n- Free tier.\n",
3404
+ # Document furniture, not staccato. Wording follows a NASA hazard
3405
+ # report form, a NIST publication citation line and a NIST control
3406
+ # enumeration -- US government works.
3407
+ "Acronym e. OpNom f. Hazard System record number g.",
3408
+ "Phone q. Fax r. e-mail s.",
3409
+ "Natl. Inst. Stand. Technol.",
3410
+ "What type of event occurred; b. When the event occurred; c. Where the event occurred; d.",
3411
+ "o Shop was not a clean area. o Lighting was not adequate. o Space was limited.",
3412
+ # Consecutive bullets are a list, whatever each one says.
3413
+ "- Nobody used it here.\n- A named owner was set.\n- Then a review happened.",
3414
+ # Transcribed speech has the staccato shape and is not prose.
3415
+ # Wording follows an NTSB cockpit voice recorder transcript.
3416
+ "we all get gas. we go to divert to Albany. we pick up a load."
3417
+ ],
3418
+ rationale: "Short sentences in a row borrow force from their rhythm, and a model " \
3419
+ "falls into the rhythm whenever it wants to sound decisive. People do it " \
3420
+ "on purpose, about thirty times per million words, so one flag is a " \
3421
+ "question; a draft where the flag repeats should be read as a warning."
3422
+ ),
1264
3423
  Rule.new(
1265
3424
  id: "em-dash",
1266
3425
  category: "structure",
@@ -1436,6 +3595,98 @@ module Sloplint
1436
3595
  "narrator who never appears. Careful writers put the verb in its own clause " \
1437
3596
  "with a subject, or leave the significance to the reader."
1438
3597
  ),
3598
+ Rule.new(
3599
+ id: "trailing-restatement",
3600
+ category: "structure",
3601
+ severity: "info",
3602
+ default_on: false,
3603
+ # The restating tail: "…, which means working through the process
3604
+ # rather than around it". A regex sees the connective and not whether
3605
+ # the tail says the head again, and the sentence that prompted the rule
3606
+ # shares no content words between the two, so no overlap test reaches
3607
+ # it either. Hence off by default.
3608
+ #
3609
+ # Three connectives, and four participle frames that make the same
3610
+ # move. Nothing before the comma is inspected: a gloss ("_ma_, which
3611
+ # means hand") and a real tell after a code span or a bold phrase end
3612
+ # on the same characters, so a guard there costs more hits than it
3613
+ # saves. Glosses are a known cost of the rule. The participle
3614
+ # "meaning" must open on one of a closed set of determiners and
3615
+ # pronouns, which keeps out the noun ("meaning of"), the intention
3616
+ # ("meaning to come back") and the bare-noun gloss ("meaning six"); a
3617
+ # gloss that takes an article ("meaning the red stick") gets through.
3618
+ # Each participle frame needs a pronoun object and a closing word,
3619
+ # because the bare participle is ordinary English ("she left, leaving
3620
+ # the door open"). The closer on "making" is a comparative or
3621
+ # (im)possible and must end the clause or lead into "to", "for" or
3622
+ # "than", since "-er" alone is also "wonder", "offer" and "her". These
3623
+ # verbs stay off trailing-significance-participle, whose list is closed
3624
+ # to verbs an event can be the subject of.
3625
+ pattern: /,#{WRAP_GAP}+
3626
+ (?:which#{WRAP_GAP}+means\b
3627
+ |which#{WRAP_GAP}+is#{WRAP_GAP}+to#{WRAP_GAP}+say\b
3628
+ |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
3629
+ |making#{WRAP_GAP}+(?:it|them|us|you|the(?:#{WRAP_GAP}+[\w'’-]+){1,2})#{WRAP_GAP}+
3630
+ (?:[a-z]+er|(?:im)?possible|(?:more|less)(?:#{WRAP_GAP}+[a-z]+)?)
3631
+ (?=#{WRAP_GAP}+(?:to|for|than)\b|[ \t]*[.,;:!?)]|[ \t]*\r?\n|[ \t]*\z)
3632
+ |allowing#{WRAP_GAP}+(?:it|them|us|you)#{WRAP_GAP}+to\b
3633
+ |giving#{WRAP_GAP}+(?:them|us|you)#{WRAP_GAP}+(?:more|less|time|room)\b
3634
+ |leaving#{WRAP_GAP}+(?:them|us|you)#{WRAP_GAP}+with(?:out)?\b)/ix,
3635
+ message: "Trailing clause that says the sentence again, or hangs a result off it.",
3636
+ suggestion: "Cut the tail, or if it states a real consequence, make it its own sentence.",
3637
+ examples_bad: [
3638
+ "We moved the checks into the build step, which means the errors show up before anyone opens a review.",
3639
+ "The new queue drains in order, which is to say nothing jumps ahead of an older job.",
3640
+ "Every job now records its own start time, meaning the log tells you when the run began.",
3641
+ "He signed at once, meaning he had read it already.",
3642
+ "We rebuilt the parser, making the whole pipeline faster.",
3643
+ "The cache is local now, making it easier to reason about.",
3644
+ "The change is small, making it more robust.",
3645
+ "The lock is per row, making it impossible for two writers to collide.",
3646
+ "The cache now lives beside the worker, allowing us to skip the round trip.",
3647
+ "The report ships on Fridays, giving them more time to read it.",
3648
+ "The old flags are gone, leaving you with one switch to learn.",
3649
+ # Nothing before the comma is inspected.
3650
+ "The flag defaults to `false`, which means nothing is written to disk.",
3651
+ "- **Cache is local**, which means the round trip is gone.",
3652
+ "We fixed the bug (the null check), which means the crash is gone.",
3653
+ # Either gap may hard-wrap.
3654
+ "We moved the checks into the build step,\nwhich means the errors show up first.",
3655
+ "We moved the checks into the build step, which\nmeans the errors show up first."
3656
+ ],
3657
+ examples_ok: [
3658
+ # "meaning" outside the closed set: the bare-noun gloss, the intention, the noun.
3659
+ "They count on with tatisitupe, meaning six.",
3660
+ "It is an old word, meaning caves.",
3661
+ "He left the room, meaning to come back before dark.",
3662
+ "She read on, meaning of the word aside, and let it pass.",
3663
+ "He shrugged, meaning no harm by it.",
3664
+ # The bare participles are ordinary English.
3665
+ "She left the room, leaving the door open.",
3666
+ "Cut the paper around the frame, leaving a margin for pasting.",
3667
+ "He handed over the keys, giving her a nod.",
3668
+ "The lid lifts off, allowing the steam to escape.",
3669
+ # A pronoun object with no closing word, or the wrong one.
3670
+ "Rub down the leather, making it as smooth as possible.",
3671
+ "The crowd parted, making room for more chairs.",
3672
+ "He pulled the cork, giving it time to breathe.",
3673
+ "He shut the door, leaving them to it.",
3674
+ "The porter took the trunk, leaving it with the station master.",
3675
+ # "-er" that is not a comparative, and a comparative that does not close the clause.
3676
+ "The rain kept up all week, making you wonder whether the trip was worth it.",
3677
+ "She turned the coat inside out, making it her own.",
3678
+ "They argued, making the same point over and over.",
3679
+ "The tide turned, making it matter less than before.",
3680
+ # A gerund list.
3681
+ "The work involves cutting, making and sanding the parts.",
3682
+ # A paragraph break is not a comma.
3683
+ "The checks moved into the build step\n\nWhich means the errors show up first."
3684
+ ],
3685
+ rationale: "The tail after the connective says the head again in other words, and a " \
3686
+ "model adds one whenever a sentence feels short of a point. Careful writers " \
3687
+ "use the same connective to state a consequence, and the pattern cannot " \
3688
+ "tell the two apart, so the rule is off by default."
3689
+ ),
1439
3690
  # ── hedging ───────────────────────────────────────────────────────────
1440
3691
  Rule.new(
1441
3692
  id: "vague-attribution",