sloplint 0.1.0 → 0.3.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 2530123f6520e2abc31a875cc53787cc4492669c2bc7919c5ab19fa3541601a1
4
- data.tar.gz: 61b2542547165a0f8e632c2ce89a679386edc8bbde24a71f92f69091603bf6e8
3
+ metadata.gz: 2dff756c71ef10086a95d721978cb5bd0f3a73ae28f46637f91b13b016aa461b
4
+ data.tar.gz: 543c0b9005b4e7b90d35c5595ee812f77d849513163d222b1ae04cb8fa48b8c0
5
5
  SHA512:
6
- metadata.gz: 607445e1bdb23eacb5d460b01e72d1ebb140136974c7988ebdd023c7db4cccfd4540e1e4ddb68031938fe936d73d173c52d0818d3573ea045cbbc0b7db46696c
7
- data.tar.gz: 55faafabedfbde617ea5cfed7e3ac89a3e7e82f24521346b21b6ddc3fe0a775e41dd1bfeacc5860326fcfaa7ab006316a9b9ab6b09fbe956678cf6de172a71c5
6
+ metadata.gz: 537e1d987a39cb4b9c488b69ea4aa7e5381b3594d9565eb78324640389e6eae7fa9cf47c6c93921048b396de24178b969f6ac36685dec2606845c9e00694b543
7
+ data.tar.gz: 9d4d9845f6a540954446dd2a5513cd0dc0fd9c28a8e689ac6a64a975f03bf68e1478c62be96e2d09ac1784e83bb421c667898f88e89aceb6a63d729ffce17270
data/CHANGELOG.md CHANGED
@@ -3,7 +3,82 @@
3
3
  All notable changes to this project are documented here. Format loosely
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
- ## [0.1.0] - Unreleased
6
+ ## [Unreleased]
7
+
8
+ ## [0.3.0] - 2026-08-02
9
+
10
+ - New rule `load-bearing` (`rhetorical-tic`, `warning`): "load-bearing" used
11
+ as a borrowed metaphor for anything important, outside its literal
12
+ construction sense (a wall, a column, a beam). Guards on both sides —
13
+ a building noun right after it, or the predicate form ("the wall is
14
+ load-bearing") — leave the construction sense clean.
15
+ - New rule `is-is` (`rhetorical-tic`, `warning`): the doubled copula, "what it
16
+ is is a mistake" / "the thing is, is that...". No anchor needed -- the bare
17
+ pattern scored 0 hits across ~1.9M words of public-domain and modern prose.
18
+ - `exactly-the` replaced by the broader `exact-exactly` (`rhetorical-tic`,
19
+ now `info`): flags "exact/exactly" generally, not just the fixed "exactly
20
+ the point/kind/problem/…" phrase shape, with an allowlist for the places
21
+ it's doing real work (numbers, times, same/opposite/way/etc.). Severity
22
+ drops to `info` because a common word will still slip through on cases
23
+ the allowlist hasn't seen yet.
24
+ - `thats-not-nothing` replaced by the broader `not-nothing`
25
+ (`rhetorical-tic`, `warning`): catches the "is not nothing" litotes
26
+ regardless of subject ("Fifty basis points is not nothing"), not only
27
+ the demonstrative-subject form ("that's not nothing"). Personal-subject
28
+ litotes ("he was not nothing to her") and the "there is not nothing"
29
+ philosophy frame are allowlisted as legitimate human use.
30
+ - `underscores-highlights` broadened (`puffery`, still `info`): "underscores
31
+ the need for", "underscored how fragile", and "underscoring the urgency"
32
+ now match, not only the fixed "underscores/highlights/emphasizes its/the/
33
+ their importance/significance" phrase. The character-noun sense
34
+ ("a leading underscore", snake_case) stays excluded.
35
+
36
+ ## [0.2.0] - 2026-07-28
37
+
38
+ - `is-real-and-not` and `worth-naming` move from `warning` to `info`. Both
39
+ patterns match on surface form alone and can't tell the AI cadence from an
40
+ unrelated sentence that happens to share it: "is real, and/but/not" fires on
41
+ any predicate-adjective sentence regardless of what follows the conjunction
42
+ ("my debt to my senses is real and constant" -- Emerson), and "worth naming"
43
+ collapses the AI meta-signpost with the plain sense of a thing worth
44
+ mentioning ("the only thing worth naming to do that" -- Emerson). Probed
45
+ against a 1.06M-word corpus (Austen, Melville, Madison, Thoreau, Emerson);
46
+ the other 19 rules still at `warning` were probed too and held up clean.
47
+ - `puffery-words`' bare `nestled` matched the literal verb as often as the
48
+ puffery sense -- a head nestled against a shoulder, a kitten nestled into a
49
+ blanket. It now requires a following in/among/between, the shape the
50
+ travel-brochure cliché actually takes ("nestled in the hills").
51
+ - New rule `not-x-but-y` (`structure`, `info`): the bare corrective "is not A
52
+ but B" with no escalation word, comma before "but" or none. Ships at `info`
53
+ because the line between a corrective and an ordinary concession is
54
+ syntactic, and a pattern can only approximate it — a concession with an
55
+ elided subject ("was not perfect but got us there") still gets through.
56
+ - New rule `no-x-no-y-frag` (`rhetorical-tic`, `info`): the "no X, no Y"
57
+ cadence built from sentence fragments ("No fluff. No filler.") rather than
58
+ commas. `info`, not `warning`, because two short "no" sentences in a row is
59
+ also just writing.
60
+ - `not-just-x-but-y` now also catches "not because A, but because B" and the
61
+ escalation words `merely`, `simply`, and `solely` alongside `just`/`only`.
62
+ It keeps `warning`: the escalation word is a deliberate authorial move.
63
+ - Both `not-…-but-…` rules now stop at a paragraph break, so an unpunctuated
64
+ heading or list item no longer joins up with the next paragraph's "But …".
65
+ - `no-x-no-y-frag` links must begin a sentence, so an ordinary sentence can no
66
+ longer donate its tail to a chain ("There was no bread. No milk either." is
67
+ one sentence and one fragment, not a chain), and the link separator is
68
+ capped at two spaces so a code span blanked by `--markdown` cannot weld two
69
+ distant fragments together.
70
+ - `sloplint explain` no longer breaks its aligned fixture block when a fixture
71
+ contains a newline; those are escaped as `\n`.
72
+ - Notes carry a new `context` field: the match bracketed inside ~40 characters
73
+ of surrounding prose. Human output shows it in place of the bare match, which
74
+ told you nothing when the match was a single word or a lone em dash. `excerpt`
75
+ is unchanged and still the bare match.
76
+ - New rule `em-dash` (`structure`, `info`): flags every em dash, not just
77
+ paragraphs dense with them.
78
+ - `em-dash-overuse` (3+ em dashes in one paragraph) is now `warning`, up from
79
+ `info`.
80
+
81
+ ## [0.1.0] - 2026-07-24
7
82
 
8
83
  First public release.
9
84
 
data/README.md CHANGED
@@ -40,20 +40,25 @@ cat draft.md | sloplint check --markdown -o json -
40
40
  The human-readable form drops `-o json`:
41
41
 
42
42
  ```
43
- $ echo "A rich tapestry of vibrant cultures. That's exactly the point." | sloplint check -
44
- -:1:3: warning rich-tapestry "rich tapestry"/"tapestry of" is a signature AI cliché.
45
- excerpt: rich tapestry
43
+ $ printf 'The report is a rich tapestry of vibrant detail.\nThat is exactly the point I keep making about it.\n' | sloplint check -
44
+ -:1:17: warning rich-tapestry "rich tapestry"/"tapestry of" is a signature AI cliché.
45
+ excerpt: The report is a [rich tapestry] of vibrant detail. That is exactly the…
46
+ why: 'tapestry of' is one of the most reliable single-phrase model tells.
46
47
  fix: Cut the metaphor; name the actual things.
47
48
 
48
- -:1:20: warning puffery-words Wikipedia-style puffery word/phrase — a common AI tell.
49
- excerpt: vibrant
49
+ -:1:34: warning puffery-words Wikipedia-style puffery word/phrase — a common AI tell.
50
+ excerpt: The report is a rich tapestry of [vibrant] detail. That is exactly the point I…
51
+ why: Travel-brochure adjectives and phrases that models reach for and careful writers avoid.
50
52
  fix: Replace with a concrete, specific detail or cut it.
51
53
 
52
- -:1:38: warning exactly-the "exactly the point/kind/problem/…" is an overused LLM emphasis tic.
53
- excerpt: That's exactly the point
54
- fix: Drop 'exactly the'; state the point without the intensifier.
54
+ -:2:9: info exact-exactly "exact/exactly" is reflexive emphasis unless it names something checkable.
55
+ excerpt: …tapestry of vibrant detail. That is [exactly] the point I keep making about it.
56
+ why: Models reach for 'exact/exactly' as filler emphasis on a claim with nothing to check; it earns its place only next to a number, a name, or a stated identity.
57
+ fix: Cut it, or replace with the number, name, or match it's supposed to be precise about.
55
58
  ```
56
59
 
60
+ The brackets mark the match; the rest is there so you can see what you're fixing without opening the file — including *why*, so an agent doesn't have to run `explain` separately to decide whether a flag is worth acting on.
61
+
57
62
  ## Commands
58
63
 
59
64
  ```
@@ -79,7 +84,7 @@ no-x-no-y (rhetorical-tic, warning)
79
84
 
80
85
  "No X, no Y" chain (%{count} items) reads as AI cadence.
81
86
 
82
- Why: LLMs love asyndetic negation triplets. A careful writer rarely stacks three.
87
+ Why: Asyndetic negation chains are a signature model cadence, near-absent from human prose at any length -- 24 hits in 1.02M words across Austen, Melville, Madison, Thoreau, and Emerson combined. A careful writer occasionally stacks two (and, rarely, more), but a model reaches for the pattern constantly.
83
88
  Fix: Cut the chain or make it one plain sentence.
84
89
 
85
90
  Flags: No fluff, no filler, no jargon.
@@ -100,12 +105,14 @@ One match is one note. JSON output is an array of these, or an object keyed by p
100
105
  "category": "rhetorical-tic",
101
106
  "message": "\"No X, no Y\" chain (3 items) reads as AI cadence.",
102
107
  "excerpt": "No fluff, no filler, no jargon",
108
+ "context": "The report was blunt. [No fluff, no filler, no jargon]. Nothing held back at all.",
103
109
  "count": 3,
110
+ "rationale": "Asyndetic negation chains are a signature model cadence, near-absent from human prose at any length -- 24 hits in 1.02M words across Austen, Melville, Madison, Thoreau, and Emerson combined. A careful writer occasionally stacks two (and, rarely, more), but a model reaches for the pattern constantly.",
104
111
  "suggestion": "Cut the chain or make it one plain sentence."
105
112
  }
106
113
  ```
107
114
 
108
- `line` and `column` are 1-indexed and point at the start of the match. `count` appears only when the rule tallies items (a "no X, no Y" chain, a "did not, did not" chain). `suggestion` is a short fix hint.
115
+ `line` and `column` are 1-indexed and point at the start of the match. `excerpt` is the bare match; `context` is the same match bracketed inside about 40 characters of surrounding prose, which is what you want when the match is a single word or a lone em dash. A match already 40 characters long carries its own context, so `context` returns it alone rather than padding it further. `count` appears only when the rule tallies items (a "no X, no Y" chain, a "did not, did not" chain). `rationale` is why the pattern is a tell — the same text `sloplint explain` prints — so an agent deciding whether an `info` flag is worth acting on doesn't have to run `explain` separately to find out. `suggestion` is a short fix hint.
109
116
 
110
117
  ## Exit codes
111
118
 
@@ -121,15 +128,17 @@ An unknown id or category in `--select`/`--ignore` is a usage error (exit 2, nam
121
128
 
122
129
  ## The rule catalog
123
130
 
124
- 24 rules across four categories. `sloplint rules` prints them; `sloplint rules --json` gives an agent the enumerable form.
131
+ 29 rules across four categories. `sloplint rules` prints them; `sloplint rules --json` gives an agent the enumerable form.
125
132
 
126
- - **rhetorical-tic** (15) the cadence patterns: `no-x-no-y`, `thats-the-whole`, `thats-how-x`, `announced-takeaway`, `exactly-the`, `you-already-know`, `sit-with-that`, `thats-not-nothing`, and more.
133
+ - **rhetorical-tic** (18) the cadence patterns: `no-x-no-y`, `no-x-no-y-frag`, `thats-the-whole`, `thats-how-x`, `announced-takeaway`, `exact-exactly`, `load-bearing`, `you-already-know`, `sit-with-that`, `not-nothing`, `is-is` (doubled copula), and more.
127
134
  - **puffery** (5) Wikipedia's "signs of AI writing": `puffery-words` (vibrant, nestled, groundbreaking, in the heart of), `rich-tapestry`, `vital-role`, `stands-serves-as`, `underscores-highlights`.
128
- - **structure** (3) `not-just-x-but-y`, `em-dash-overuse` (three or more em dashes in a paragraph), and `rule-of-three`.
135
+ - **structure** (5) `not-just-x-but-y`, `not-x-but-y` (the bare corrective), `em-dash` (any em dash), `em-dash-overuse` (three or more in one paragraph), and `rule-of-three`.
129
136
  - **hedging** (1) `vague-attribution`: "some critics argue," "it is widely regarded."
130
137
 
131
138
  Severity is `warning` for strong tells, `info` for weak or contextual ones. No rule currently ships at `error`; the tier is reserved for a pattern with essentially zero false-positive risk, and none has earned that yet.
132
139
 
140
+ Some tells come in a confident form and an ambiguous one, and those ship as a pair rather than as one rule stretched over both. `no-x-no-y` wants the comma chain a writer clearly authored; `no-x-no-y-frag` takes the same cadence built from sentence fragments, which ordinary prose also produces, and ships at `info`. Same with `not-just-x-but-y` and `not-x-but-y`. The quiet half is still worth flagging — an agent that reads the rationale can judge — but it should not carry the same weight as the half we're sure about.
141
+
133
142
  One rule ships **off by default**: `rule-of-three` flags three parallel comma items closing a sentence, which humans do all the time, so it false-positives. It runs only when you name it: `sloplint check --select rule-of-three -`.
134
143
 
135
144
  ### Markdown handling
@@ -142,20 +151,23 @@ Rules are data, not code. Each is a `Data.define` object in `lib/sloplint/rules.
142
151
 
143
152
  ```ruby
144
153
  Rule.new(
145
- id: "no-x-no-y",
146
- category: "rhetorical-tic",
147
- severity: "warning",
148
- pattern: /\bno\s+[\w'-]+,\s+no\s+[\w'-]+(?:,?\s+(?:and\s+)?no\s+[\w'-]+)*/i,
149
- message: '"No X, no Y" chain (%{count} items) reads as AI cadence.',
150
- suggestion: "Cut the chain or make it one plain sentence.",
151
- count_group: /\bno\b/i, # optional: a regex tallied over the match
152
- skip: [/real estate/i], # optional: drop the note if these match
153
- examples_bad: ["No fluff, no filler, no jargon."],
154
- examples_ok: ["No parking on Sundays."],
155
- rationale: "Asyndetic negation chains are a signature model cadence, rare in human prose."
154
+ id: "rule-id",
155
+ category: "rhetorical-tic", # or puffery, structure, hedging
156
+ severity: "warning", # or info
157
+ pattern: /.../i,
158
+ message: "What the reader sees. %{count} interpolates the tally.",
159
+ suggestion: "One short fix hint.",
160
+ count_group: /.../i, # optional: a regex tallied over the match
161
+ skip: [/.../i], # optional: drop the note if these match
162
+ default_on: false, # optional: runs only when named in --select
163
+ examples_bad: ["A sentence the rule must flag."],
164
+ examples_ok: ["A sentence it must leave alone."],
165
+ rationale: "Why this is a tell, and what it costs when it's wrong."
156
166
  )
157
167
  ```
158
168
 
169
+ `rationale` is not decoration. `sloplint explain` prints it, and that is what an agent reads to decide whether a flag is worth acting on — so it should say what the pattern is, how it was probed, and where it's known to be weak.
170
+
159
171
  Adding a rule is one entry plus its fixtures. `rules_spec.rb` iterates the catalog and asserts every `examples_bad` produces at least one note and every `examples_ok` produces none, so a rule without fixtures, or one whose regex is too greedy, fails the suite.
160
172
 
161
173
  ## Development
data/docs/SPEC.md CHANGED
@@ -26,6 +26,31 @@ trust a flag instead of second-guessing it), and no overlap with tools that
26
26
  already do general prose well. When a rule is borderline, ask the test again and
27
27
  cut it if the answer is soft.
28
28
 
29
+ ## Provenance: what may be committed
30
+
31
+ Finding rules means reading a great deal of other people's writing: slop in the
32
+ wild to spot a tell, careful human prose to check a candidate against. Reading it
33
+ is fine. Committing it is not. A regex carries nothing of the text it came from,
34
+ so the source never needs to enter the repository.
35
+
36
+ - **No corpus is committed**, on either side. Collection notes stay out of the
37
+ repo.
38
+ - **`examples_bad` are written, not lifted.** When a real sentence is the only
39
+ illustration on hand, rewrite it until it carries the pattern without carrying
40
+ the original wording.
41
+ - **`examples_ok` may quote real prose, public domain only**, with the source
42
+ named in a comment. The Moby-Dick and Federalist No. 44 fixtures are the model.
43
+ A common idiom or a title is not a quotation and needs no such treatment.
44
+ - **`rationale:` states frequencies, not quotations.** "24 hits in 1.02M words"
45
+ is the form.
46
+ - **A reference corpus of human prose must be public domain.** Copyrighted text
47
+ can't be redistributed, so a corpus built from it can't live in the repo, and
48
+ neither can the false-positive check that depends on it.
49
+
50
+ This repository is MIT-licensed, so anything committed is redistributable by
51
+ anyone. Writing the fixtures ourselves also means nobody's prose gets held up as
52
+ a specimen of slop, which is reason enough on its own.
53
+
29
54
  ## Prior art we're borrowing from
30
55
 
31
56
  - **proselint** — subcommand CLI (`check`, `version`, `dump-config`), `--output-format full|json|compact`, LSP-style diagnostics (line/column/severity/code/message), config file, clean exit codes. We copy this shape.
@@ -145,14 +170,28 @@ path when multiple files are scanned).
145
170
  "rule": "no-x-no-y",
146
171
  "category": "rhetorical-tic",
147
172
  "message": "\"No X, no Y\" chain (3 items) reads as AI cadence.",
148
- "excerpt": "No fluff, no filler, no jargon.",
173
+ "excerpt": "No fluff, no filler, no jargon",
174
+ "context": "The report was blunt. [No fluff, no filler, no jargon]. Nothing held back at all.",
149
175
  "count": 3,
176
+ "rationale": "Asyndetic negation chains are a signature model cadence, near-absent from human prose at any length -- 24 hits in 1.02M words across Austen, Melville, Madison, Thoreau, and Emerson combined. A careful writer occasionally stacks two (and, rarely, more), but a model reaches for the pattern constantly.",
150
177
  "suggestion": "Cut the chain or make it one plain sentence."
151
178
  }
152
179
  ```
153
180
 
154
181
  - `line`/`column` are 1-indexed, pointing at the start of the match.
182
+ - `excerpt` is the bare match, nothing else. It is what `column` points at.
183
+ - `context` is the match bracketed inside ~40 characters of surrounding prose,
184
+ whitespace collapsed, with `…` on any end that was cut. It exists because a
185
+ bare `excerpt` says nothing useful when the match is one word or a lone em
186
+ dash. A match already 40 characters long carries its own context, so `context`
187
+ returns it alone rather than padding it out further. The window is sliced from
188
+ the text as written, so `--markdown` still shows real code and URLs here even
189
+ though they were blanked before matching.
155
190
  - `count` present when the rule counts items (the "badge" in the examples).
191
+ - `rationale` is the same text `sloplint explain` prints under `Why:` — why the
192
+ pattern reads as a tell. `check` carries it on every note so an agent acting
193
+ on an `info` flag (or deciding whether to) doesn't have to shell out to
194
+ `explain` first; that's the whole point of the field.
156
195
  - `suggestion` is a short fix hint; agents may use it, humans see it too.
157
196
 
158
197
  ## Rule model
@@ -211,7 +250,7 @@ Categories (for `--select`/`--ignore` by group):
211
250
 
212
251
  - `rhetorical-tic` — the cadence patterns (the user's list below)
213
252
  - `puffery` — Wikipedia "words to watch" (boasts, vibrant, nestled, tapestry…)
214
- - `structure` — rule-of-three, "not just X but Y", em-dash overuse
253
+ - `structure` — rule-of-three, "not just X but Y", em dash, em-dash overuse
215
254
  - `hedging` — vague attribution ("some critics argue", "it is widely regarded")
216
255
 
217
256
  Severities: `warning` for strong tells, `info` for weak/contextual ones. No
@@ -224,7 +263,8 @@ false-positive risk, which none has demonstrated.
224
263
 
225
264
  | id | catches | notes |
226
265
  |----|---------|-------|
227
- | `no-x-no-y` | 2+ "no …" items in a row | counts items |
266
+ | `no-x-no-y` | 2+ comma-separated "no …" items in a row | counts items |
267
+ | `no-x-no-y-frag` | the same cadence as sentence fragments ("No fluff. No filler.") | counts items; `info` |
228
268
  | `thats-the-whole` | "that/this is the whole point/game/thing…" | |
229
269
  | `did-not-x-did-not-y` | 2+ "did not …"/"didn't …" in a row | counts items |
230
270
  | `dont-verb-it` | "Don't call it X. Call it Y." (negated verb+it, same verb+it) | |
@@ -232,26 +272,29 @@ false-positive risk, which none has demonstrated.
232
272
  | `you-already-know` | "you already know" (+ the answer / standalone) | |
233
273
  | `is-the-entire` | "X is the entire point/game/business model" | |
234
274
  | `the-entire-is` | "the entire point/game/… is" (flip of above) | |
235
- | `is-real-and-not` | "the X is real, and/not…", "is the real … and it" | skip "real estate/time" |
275
+ | `is-real-and-not` | "the X is real, and/not…", "is the real … and it" | skip "real estate/time"; `info` |
236
276
  | `the-punchline-is` | "the punchline is/:/?" | |
237
- | `worth-naming` | "worth naming", "it's worth naming that…", "Worth naming:" | skip "naming names" |
238
- | `thats-not-nothing` | "that/this/it/which is not nothing" | |
277
+ | `worth-naming` | "worth naming", "it's worth naming that…", "Worth naming:" | skip "naming names"; `info` |
278
+ | `not-nothing` | copula + "not nothing" litotes, any subject | skip personal/there subjects |
279
+ | `is-is` | doubled copula: "what it is is …", "the thing is, is that …" | comma optional |
239
280
 
240
281
  ### puffery (Wikipedia: Signs of AI writing)
241
282
 
242
283
  Single flat rule per word-cluster, matched as whole words:
243
284
 
244
- - `puffery-words` — boasts a, vibrant, rich (history/cultural/tapestry), nestled, in the heart of (gated to a place object), groundbreaking, renowned, diverse array, breathtaking, natural beauty, stands as a testament, indelible mark, deeply rooted.
285
+ - `puffery-words` — boasts a, vibrant, rich (history/cultural/tapestry), nestled (gated to a following in/among/between, so the literal verb — a head nestled against a shoulder — doesn't count), in the heart of (gated to a place object), groundbreaking, renowned, diverse array, breathtaking, natural beauty, stands as a testament, indelible mark, deeply rooted.
245
286
  - `stands-serves-as` — "stands as / serves as", "is a testament/reminder to".
246
287
  - `vital-role` — "plays a (vital/crucial/pivotal/significant/key) role".
247
- - `underscores-highlights` — "underscores/highlights/emphasizes its (importance/significance)".
288
+ - `underscores-highlights` — "underscore(s)" + determiner and "underscored/underscoring" anywhere (the emphasis verb); "highlights/emphasizes its (importance/significance)" stays narrow.
248
289
  - `rich-tapestry` — "rich tapestry", "tapestry of".
249
290
 
250
291
  ### structure
251
292
 
252
- - `not-just-x-but-y` — "not just X, but (also) Y", "not only but".
293
+ - `not-just-x-but-y` — copula + "not just/only/merely/simply/solely X but (also) Y", plus "not because X, but because Y". Requires the escalation word.
294
+ - `not-x-but-y` — the bare corrective "is not X but Y" with no escalation word; `info`, because the corrective/concession distinction is syntactic and the pattern can only approximate it.
253
295
  - `rule-of-three` — three parallel comma items ending a sentence (heuristic; `info` severity, off by default via `--select` since it false-positives).
254
- - `em-dash-overuse` — >N em dashes per paragraph (default N=2); `info`.
296
+ - `em-dash` — any em dash; `info`.
297
+ - `em-dash-overuse` — 3+ em dashes in one paragraph; `warning`.
255
298
 
256
299
  ### hedging
257
300
 
@@ -274,7 +317,7 @@ This is a first-class requirement, not an afterthought.
274
317
  # Recommended for agents:
275
318
  cat FILE | sloplint check --markdown -o json -
276
319
  # exit 0 = clean, 1 = notes found, >1 = error
277
- # each note: {path,line,column,severity,rule,message,excerpt,suggestion}
320
+ # each note: {path,line,column,severity,rule,category,message,excerpt,context,rationale,suggestion}
278
321
  ```
279
322
 
280
323
  - Every option has a full-sentence help string (no telegraphic fragments).
@@ -306,3 +349,7 @@ RSpec (dev dependency), run via `rake spec`.
306
349
  - Non-English. Languages other than English are a v2 conversation.
307
350
  - ML/embedding-based detection. This is a regex linter on purpose — fast,
308
351
  explainable, zero-dependency. Statistical detection is a different product.
352
+ - Scraped corpora. Platform terms prohibit automated collection, and finding a
353
+ repeated shape needs hundreds of samples rather than millions, so collection
354
+ stays manual. Analysis is offline development work either way; the shipped
355
+ runtime stays regex plus stdlib.
data/lib/sloplint/cli.rb CHANGED
@@ -142,12 +142,20 @@ module Sloplint
142
142
  Why: #{rule.rationale}
143
143
  Fix: #{rule.suggestion}
144
144
 
145
- Flags: #{rule.examples_bad.join("\n ")}
146
- Does not: #{rule.examples_ok.join("\n ")}
145
+ Flags: #{fixture_list(rule.examples_bad)}
146
+ Does not: #{fixture_list(rule.examples_ok)}
147
147
  TXT
148
148
  0
149
149
  end
150
150
 
151
+ # One fixture per line, aligned under the label. Fixtures may contain
152
+ # newlines (a chain that survives a hard wrap, one that dies at a paragraph
153
+ # break), so escape them rather than letting a fixture break the block --
154
+ # `explain` is parsed by agents, and a stray newline reads as end-of-list.
155
+ def fixture_list(examples)
156
+ examples.map { |e| e.gsub("\r", '\r').gsub("\n", '\n') }.join("\n ")
157
+ end
158
+
151
159
  # ── helpers ─────────────────────────────────────────────────────────────
152
160
  # Ids/categories in refs that match no rule in the catalog. nil (no --select
153
161
  # or --ignore given) passes through as no unknowns.
@@ -180,7 +188,7 @@ module Sloplint
180
188
  # Recommended for agents:
181
189
  cat FILE | sloplint check --markdown -o json -
182
190
  # exit 0 = clean, 1 = notes found, >1 = error
183
- # each note: {path,line,column,severity,rule,category,message,excerpt,suggestion}
191
+ # each note: {path,line,column,severity,rule,category,message,excerpt,context,rationale,suggestion}
184
192
 
185
193
  usage: sloplint [-o full|json] <command> [args]
186
194
 
@@ -6,15 +6,18 @@ module Sloplint
6
6
  # One match = one Note. See docs/SPEC.md "Note".
7
7
  Note = Data.define(
8
8
  :path, :line, :column, :severity, :rule, :category,
9
- :message, :excerpt, :count, :suggestion
9
+ :message, :excerpt, :context, :count, :rationale, :suggestion
10
10
  )
11
11
 
12
12
  module Engine
13
+ CONTEXT_CHARS = 40
14
+
13
15
  module_function
14
16
 
15
17
  # text: the source. rules: which Rule objects to run. markdown: blank code/URLs first.
16
18
  # path: label carried into each Note (e.g. filename or "-" for stdin).
17
19
  def scan(text, rules: RULES, markdown: false, path: "-")
20
+ source = text
18
21
  text = blank_markdown(text) if markdown
19
22
  line_starts = line_starts_for(text)
20
23
  notes = []
@@ -31,13 +34,40 @@ module Sloplint
31
34
  path: path, line: line, column: column,
32
35
  severity: rule.severity, rule: rule.id, category: rule.category,
33
36
  message: message, excerpt: matched.gsub(/\s+/, " ").strip,
34
- count: count, suggestion: rule.suggestion
37
+ context: context_for(source, m),
38
+ count: count, rationale: rule.rationale, suggestion: rule.suggestion
35
39
  )
36
40
  end
37
41
  end
38
42
  notes.sort_by { |n| [n.line, n.column] }
39
43
  end
40
44
 
45
+ # The match plus ~CONTEXT_CHARS either side, bracketed, whitespace collapsed.
46
+ # A bare `excerpt` is useless for a rule whose match is one character -- see
47
+ # em-dash, where the note said only "—" and you had to open the file and
48
+ # count to the column to learn anything.
49
+ #
50
+ # Truncated ends get an ellipsis and are trimmed back to a word boundary so
51
+ # the window doesn't open mid-word. A match already CONTEXT_CHARS long
52
+ # carries its own context; padding it just makes an em-dash-overuse span
53
+ # (which can run a whole paragraph) longer for no gain, so those return the
54
+ # match alone.
55
+ #
56
+ # source is the PRE-blanking text. blank_markdown replaces each non-newline
57
+ # char with one space, so offsets are identical either way, but a window
58
+ # over blanked text shows code and URLs as a run of spaces. Offsets here are
59
+ # character offsets (MatchData#begin), matching the char-based line_starts_for.
60
+ def context_for(source, match)
61
+ return "[#{match[0].gsub(/\s+/, " ").strip}]" if match[0].length >= CONTEXT_CHARS
62
+
63
+ b, e = match.begin(0), match.end(0)
64
+ pre = source[[b - CONTEXT_CHARS, 0].max...b]
65
+ post = source[e, CONTEXT_CHARS].to_s
66
+ pre = "…#{pre.sub(/\A\S*\s+/, "")}" if b > CONTEXT_CHARS
67
+ post = "#{post.sub(/\s+\S*\z/, "")}…" if e + CONTEXT_CHARS < source.length
68
+ "#{pre}[#{match[0]}]#{post}".gsub(/\s+/, " ").strip
69
+ end
70
+
41
71
  # 1-indexed line and column for a char offset into text. Binary-searches a
42
72
  # precomputed line_starts table (see line_starts_for) so a scan with many
43
73
  # notes doesn't re-walk the prefix from offset 0 for every single one --
@@ -12,9 +12,10 @@ module Sloplint
12
12
 
13
13
  notes.map do |n|
14
14
  head = "#{n.path}:#{n.line}:#{n.column}: #{n.severity} #{n.rule} #{n.message}"
15
- excerpt = " excerpt: #{n.excerpt}"
15
+ excerpt = " excerpt: #{n.context}"
16
+ why = " why: #{n.rationale}"
16
17
  fix = " fix: #{n.suggestion}"
17
- [head, excerpt, fix].join("\n")
18
+ [head, excerpt, why, fix].join("\n")
18
19
  end.join("\n\n")
19
20
  end
20
21
 
@@ -22,6 +22,10 @@ module Sloplint
22
22
  id: "no-x-no-y",
23
23
  category: "rhetorical-tic",
24
24
  severity: "warning",
25
+ # Comma chains only: "no fluff, no filler, no jargon". The comma is the
26
+ # evidence -- it makes the parallelism deliberate. Fragment chains split
27
+ # by sentence punctuation are a separate, quieter rule (no-x-no-y-frag),
28
+ # because periods are not an authorial choice the way commas are.
25
29
  pattern: /\bno\s+[\w'-]+,\s+no\s+[\w'-]+(?:,?\s+(?:and\s+)?no\s+[\w'-]+)*/i,
26
30
  message: '"No X, no Y" chain (%{count} items) reads as AI cadence.',
27
31
  suggestion: "Cut the chain or make it one plain sentence.",
@@ -29,9 +33,51 @@ module Sloplint
29
33
  examples_bad: ["No fluff, no filler, no jargon."],
30
34
  examples_ok: ["No parking on Sundays."],
31
35
  rationale: "Asyndetic negation chains are a signature model cadence, near-absent from " \
32
- "human prose at any length -- 24 hits in 1.02M words across Austen, Melville, " \
33
- "Madison, Thoreau, and Emerson combined. A careful writer occasionally stacks " \
34
- "two (and, rarely, more), but a model reaches for the pattern constantly."
36
+ "human prose at any length. A careful writer occasionally stacks two (and, " \
37
+ "rarely, more), but a model reaches for the pattern constantly."
38
+ ),
39
+ Rule.new(
40
+ id: "no-x-no-y-frag",
41
+ category: "rhetorical-tic",
42
+ severity: "info",
43
+ # The same cadence built from sentence fragments: "No fluff. No filler."
44
+ # Ships at info, not warning, because the shape is genuinely ambiguous --
45
+ # two short "no" sentences in a row is also just writing ("No one moved.
46
+ # No one spoke."). The agent reading the flag decides; see rationale.
47
+ #
48
+ # Two structural guards keep it from reporting chains that are not in the
49
+ # text at all. A link must START a sentence, so an ordinary sentence
50
+ # cannot donate its tail ("There was no bread." + "No milk either." is
51
+ # one sentence and one fragment, not a chain). And the separator is at
52
+ # most two spaces, so a blanked-out code span or URL under --markdown
53
+ # cannot silently weld two distant fragments together. Links may cross a
54
+ # hard-wrapped newline (\r\n included) but never a paragraph break.
55
+ pattern: /(?:^|(?<=[.;!?])[ \t]{1,2})\K
56
+ no[ \t]+[\w'-]+(?:[ \t]+[\w'-]+)?[ \t]*[.;!]
57
+ (?:(?:[ \t]{1,2}|\r?\n(?!\s*\n)[ \t]*)
58
+ (?:and[ \t]+)?no[ \t]+[\w'-]+(?:[ \t]+[\w'-]+)?[ \t]*[.;!])+/ix,
59
+ message: '"No X. No Y." fragment chain (%{count} items) reads as AI cadence.',
60
+ suggestion: "Cut the chain or make it one plain sentence.",
61
+ count_group: /\bno\b/i,
62
+ examples_bad: [
63
+ "No fluff. No filler. No jargon.",
64
+ "No fees; no contracts; no hidden charges.",
65
+ # Hard-wrapped Markdown: the chain survives one newline.
66
+ "No fluff.\nNo filler."
67
+ ],
68
+ examples_ok: [
69
+ "No parking on Sundays.",
70
+ # A link must start a sentence, so this donates no tail.
71
+ "There was no bread. No milk either.",
72
+ "Say no more. No worries.",
73
+ # Two fragments split by a paragraph break never chain.
74
+ "No answer.\n\nNo one was home when we finally arrived."
75
+ ],
76
+ rationale: "The fragment form of the same cadence, and the weaker signal of the two: the " \
77
+ "comma chain is one authored sentence, while this is just short sentences in " \
78
+ "sequence, which human prose also does. It's rare in careful writing, but a " \
79
+ "deliberate staccato run ('No one moved. No one spoke.') has the identical " \
80
+ "shape and is not a tell. Treat a flag here as a question, not a verdict."
35
81
  ),
36
82
  Rule.new(
37
83
  id: "thats-the-whole",
@@ -114,7 +160,7 @@ module Sloplint
114
160
  Rule.new(
115
161
  id: "is-real-and-not",
116
162
  category: "rhetorical-tic",
117
- severity: "warning",
163
+ severity: "info",
118
164
  pattern: /\bis\s+real,?\s+(?:and|but|not)\b/i,
119
165
  message: '"The X is real, and…" is a stock LLM concession move.',
120
166
  suggestion: "Drop the 'is real, and' scaffolding; assert the point directly.",
@@ -126,7 +172,12 @@ module Sloplint
126
172
  # phrasing it could plausibly have been guarding against.
127
173
  examples_bad: ["The risk is real, and it is growing."],
128
174
  examples_ok: ["This is real leather.", "This is real estate, and it is expensive."],
129
- rationale: "'X is real, and/but/not' is a model both-sidesing cadence."
175
+ rationale: "The pattern requires nothing about what follows the conjunction, so it fires " \
176
+ "on any 'is real' sentence that happens to continue with and/but/not, concession " \
177
+ "or not. Emerson's 'my debt to my senses is real and constant' is two predicate " \
178
+ "adjectives, not a both-sidesing move -- the AI cadence and the plain sentence " \
179
+ "are three words apart and identical on the surface. An agent reading the flag " \
180
+ "has the rest of the sentence to judge; the pattern alone doesn't."
130
181
  ),
131
182
  Rule.new(
132
183
  id: "the-punchline-is",
@@ -142,7 +193,7 @@ module Sloplint
142
193
  Rule.new(
143
194
  id: "worth-naming",
144
195
  category: "rhetorical-tic",
145
- severity: "warning",
196
+ severity: "info",
146
197
  # Widened to optionally include a trailing "names" so the "naming
147
198
  # names" idiom is part of the matched text -- skip: checks the matched
148
199
  # text itself, and the tighter /\bworth\s+naming\b/ never captured
@@ -153,30 +204,149 @@ module Sloplint
153
204
  skip: [/naming names/i],
154
205
  examples_bad: ["One tension is worth naming here."],
155
206
  examples_ok: ["It's worth reading twice.", "It's worth naming names in this report."],
156
- rationale: "'worth naming' is a model signpost that a human would simply skip."
207
+ rationale: "'worth naming' collapses two senses a regex can't tell apart: the AI " \
208
+ "meta-signpost announcing a point is coming ('One tension is worth naming " \
209
+ "here') and the plain sense of a thing worth calling or mentioning, which " \
210
+ "careful writers use too -- Emerson's 'the only thing worth naming to do that' " \
211
+ "is the latter, not the former. A flag here means the phrase is present, not " \
212
+ "which sense it's in."
157
213
  ),
158
214
  Rule.new(
159
- id: "thats-not-nothing",
215
+ id: "not-nothing",
160
216
  category: "rhetorical-tic",
161
217
  severity: "warning",
162
- pattern: /\b(?:that|this|it|which)(?:'s| is)\s+not\s+nothing\b/i,
163
- message: '"…that\'s not nothing" is a stock LLM understatement.',
218
+ # Two branches: spelled-out copula with an optional captured subject, and
219
+ # the contracted "X's not nothing". The optional subject + skip: is the
220
+ # load-bearing idiom -- skip: only sees matched text, so the personal
221
+ # subjects a human litotes takes (I/he/she/we/you/they) must be inside
222
+ # the match to be dropped there. The contracted branch captures the word
223
+ # before 's for the same reason ("she's not nothing" must reach the
224
+ # skip). "there" is guarded too: "there is/there's not nothing" is the
225
+ # philosophy frame or dialect, not the closer. "not for nothing" and
226
+ # "something and not nothing" never match at all -- the copula must sit
227
+ # adjacent to "not nothing". Token gaps cross a hard-wrapped line but
228
+ # never a paragraph break, so a paragraph ending "...was not" cannot weld
229
+ # onto one starting "Nothing ...".
230
+ pattern: /\b(?:(?:i|he|she|we|you|they|there)(?:[ \t]|\r?\n(?![ \t]*\r?\n))+)?
231
+ (?:am|is|are|was|were)(?:[ \t]|\r?\n(?![ \t]*\r?\n))+
232
+ not(?:[ \t]|\r?\n(?![ \t]*\r?\n))+nothing\b
233
+ |\b[\w-]+['’]s(?:[ \t]|\r?\n(?![ \t]*\r?\n))+
234
+ not(?:[ \t]|\r?\n(?![ \t]*\r?\n))+nothing\b/ix,
235
+ skip: [/\A(?:i|he|she|we|you|they|there)\b/i],
236
+ message: '"…is not nothing" is a stock LLM understatement.',
164
237
  suggestion: "State the magnitude directly instead of the litotes.",
165
- examples_bad: ["We cut latency in half, and that's not nothing."],
166
- examples_ok: ["That is not enough to matter."],
167
- rationale: "The 'not nothing' litotes is a recognizable model closer."
238
+ examples_bad: [
239
+ "We cut latency in half, and that's not nothing.",
240
+ "Fifty basis points is not nothing.",
241
+ "Three years of runway is not nothing.",
242
+ "A million users is not nothing.",
243
+ "The margin was not nothing.",
244
+ # Hard-wrapped Markdown: same paragraph, still the tell.
245
+ "The gain was not\nnothing, the report said."
246
+ ],
247
+ examples_ok: [
248
+ "That is not enough to matter.",
249
+ # Personal-subject litotes is a human literary move, not the closer
250
+ # tic -- spelled out and contracted.
251
+ "She told him he was not nothing to her.",
252
+ "She's not nothing to me, whatever they say.",
253
+ # "not for nothing": the intervening word breaks adjacency.
254
+ "It was not for nothing that he trained all winter.",
255
+ # The philosophy frame, both shapes it takes.
256
+ "Why is there something and not nothing?",
257
+ "There is not nothing; being persists.",
258
+ # Dialect double negative, not the litotes.
259
+ "There's not nothing we can do.",
260
+ # A paragraph break never welds a match.
261
+ "The answer was not\n\nNothing prepared us for it."
262
+ ],
263
+ rationale: "The 'not nothing' litotes is a recognizable model closer; the subject " \
264
+ "varies ('that's not nothing', 'fifty basis points is not nothing') but " \
265
+ "the move is the same. Personal subjects are carved out: 'he was not " \
266
+ "nothing to her' is a human literary litotes, not the closer."
168
267
  ),
169
268
 
170
269
  Rule.new(
171
- id: "exactly-the",
270
+ id: "exact-exactly",
271
+ category: "rhetorical-tic",
272
+ severity: "info",
273
+ 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,
274
+ message: '"exact/exactly" is reflexive emphasis unless it names something checkable.',
275
+ suggestion: "Cut it, or replace with the number, name, or match it's supposed to be precise about.",
276
+ examples_bad: [
277
+ "That's exactly the point.", "We proved exactly the point we needed.",
278
+ "This is exactly the kind of thing we warned about.",
279
+ "That's the exact problem with the old system.",
280
+ "That's exactly right.", "I know exactly why this happened."
281
+ ],
282
+ examples_ok: [
283
+ "She folded it exactly the way he showed her.",
284
+ "The bill came to exactly $42.",
285
+ "We agreed on the exact same design.",
286
+ "It's not an exact science.",
287
+ "Please bring exact change for the bus.",
288
+ "The museum built an exact replica of the ship.",
289
+ "This is an exact copy of the original.",
290
+ "He wanted the exact opposite of what she suggested.",
291
+ "Rescue teams pinpointed the exact location of the wreck.",
292
+ "GPS gave us the exact coordinates of the site.",
293
+ "The train left at exactly noon.",
294
+ "They agreed to meet at exactly midnight.",
295
+ "The meeting starts at exactly 3 o'clock.",
296
+ "She has exacting standards for her students."
297
+ ],
298
+ rationale: "Models reach for 'exact/exactly' as filler emphasis on a claim with nothing to " \
299
+ "check; it earns its place only next to a number, a name, or a stated identity."
300
+ ),
301
+ Rule.new(
302
+ id: "load-bearing",
172
303
  category: "rhetorical-tic",
173
304
  severity: "warning",
174
- pattern: /\b(?:(?:that's|this is|it's)\s+)?exactly\s+the\s+(?:point|kind|type|sort|problem|question|issue|opposite|reason|thing|tension)\b/i,
175
- message: '"exactly the point/kind/problem/…" is an overused LLM emphasis tic.',
176
- suggestion: "Drop 'exactly the'; state the point without the intensifier.",
177
- examples_bad: ["That's exactly the point.", "We proved exactly the point we needed."],
178
- examples_ok: ["She folded it exactly the way he showed her."],
179
- rationale: "'exactly the X' is a model intensifier that a careful writer rarely leans on."
305
+ # Two guards, both structural, over the same noun list so they can't
306
+ # drift apart. Forward: a physical building part right after it is the
307
+ # literal sense, checked with a negative lookahead. Backward: the
308
+ # predicate form ("the wall is load-bearing") is literal too, but a
309
+ # fixed-width lookbehind covering this many noun x tense combinations
310
+ # trips a real Ruby/Onigmo lookbehind bug on some inputs (RegexpError
311
+ # at match time, not compile time -- reproduced on em-dash's own
312
+ # fixtures). Pulling the noun+copula into the pattern itself as an
313
+ # optional leading group sidesteps lookbehind entirely: when present,
314
+ # it's captured as part of the match, and skip: (which only ever sees
315
+ # matched text, never surrounding context) drops it there instead.
316
+ pattern: /\b(?:(?:wall|column|beam|post|pillar|joist|stud|masonry|partition|
317
+ structure|frame|footing|foundation|member)s?\s+
318
+ (?:is|are|was|were)\s+)?
319
+ load[-\s]?bearing\b
320
+ (?!\s+(?:wall|column|beam|post|pillar|joist|stud|masonry|partition|
321
+ structure|frame|footing|foundation|member)s?\b)/ix,
322
+ skip: [/\A(?:wall|column|beam|post|pillar|joist|stud|masonry|partition|
323
+ structure|frame|footing|foundation|member)s?\s+
324
+ (?:is|are|was|were)\s+load/ix],
325
+ message: '"load-bearing" outside construction is a borrowed metaphor.',
326
+ suggestion: "Say what the thing holds up, or what breaks without it.",
327
+ examples_bad: [
328
+ "That comma is load-bearing.",
329
+ "The load-bearing assumption is that users read the docs.",
330
+ "Half the argument rests on one load-bearing word.",
331
+ "This paragraph is the load-bearing part of the essay.",
332
+ "Trust was the load-bearing element of the whole deal.",
333
+ "The qualifier is doing load bearing work here."
334
+ ],
335
+ examples_ok: [
336
+ "They knocked out a load-bearing wall during the remodel.",
337
+ "The inspector flagged a cracked load-bearing column.",
338
+ "Steel load-bearing beams replaced the old timber.",
339
+ "The load-bearing masonry dates to 1890.",
340
+ "Those columns are load-bearing.",
341
+ "The interior wall is load-bearing, so it stays.",
342
+ "The beam is load-bearing.",
343
+ "That stud was load-bearing, so removing it needed a header beam.",
344
+ "Those joists were load-bearing, engineers confirmed after inspection.",
345
+ "The masonry was load-bearing in the original 1890 structure."
346
+ ],
347
+ rationale: "'Load-bearing' is a construction term for a wall or column that holds up the " \
348
+ "structure. Models borrow it as a metaphor for anything important, which just " \
349
+ "restates the sentence's importance without saying what actually holds it up."
180
350
  ),
181
351
  Rule.new(
182
352
  id: "thats-how-x",
@@ -212,13 +382,52 @@ module Sloplint
212
382
  rationale: "Labelling a claim as the portable lesson does the persuading that the claim " \
213
383
  "should be doing — a model habit borrowed from thought-leader prose."
214
384
  ),
385
+ Rule.new(
386
+ id: "is-is",
387
+ category: "rhetorical-tic",
388
+ severity: "warning",
389
+ # No anchor needed -- the doubled copula alone scored 0 across ~1.9M words.
390
+ # The comma is allowed because "What it is, is a mystery" grates the same
391
+ # way. Sentence and clause punctuation still block the weld ("what it is.
392
+ # Is that…", "here's what it is: is anyone…"), and \b on both ends keeps
393
+ # the pattern out of "his island". \s+ needs no paragraph-break guard: a
394
+ # false match would need a paragraph that begins with "is ".
395
+ pattern: /\bis,?\s+is\b/i,
396
+ message: 'Doubled copula ("is is" / "is, is") — spoken cadence on the page.',
397
+ suggestion: "Drop the cleft and say it straight, or delete the second 'is'.",
398
+ examples_bad: [
399
+ "What this really is is a bet on distribution.",
400
+ "What it is is a rounding error with a press release.",
401
+ "What it is, is a failure of nerve.",
402
+ "The thing is, is that nobody checked the logs."
403
+ ],
404
+ examples_ok: [
405
+ # Walden: the cleft with a single copula, which is ordinary English.
406
+ "We have heard of this virtue, but we know not what it is.",
407
+ # Sentence and clause punctuation can't be welded across.
408
+ "I know what it is. Is that a problem?",
409
+ "Here's what it is: is anyone actually reading this?",
410
+ # Both \b anchors matter -- "his island" contains the literal string "is is".
411
+ "He sailed to his island at dawn."
412
+ ],
413
+ rationale: "Three things produce a doubled copula and all three read as unedited. The " \
414
+ "wh-cleft ('what it is is a mistake') is grammatical -- 'what it is' is the " \
415
+ "subject and the second 'is' is the verb -- and it is the model's version, a " \
416
+ "frame that stages a definition instead of asserting one, the same move as " \
417
+ "'the punchline is'. The NP form ('the thing is, is that') is the true double " \
418
+ "copula and a spoken disfluency. The third is a typo."
419
+ ),
215
420
 
216
421
  # ── puffery ───────────────────────────────────────────────────────────
217
422
  Rule.new(
218
423
  id: "puffery-words",
219
424
  category: "puffery",
220
425
  severity: "warning",
221
- pattern: /\b(?:boasts\s+a\b|vibrant|nestled|
426
+ # "nestled" alone is the literal verb as often as the puffery sense --
427
+ # a head nestling against a shoulder, a kitten nestling into a blanket
428
+ # -- so it requires a following in/among/between, the same shape the
429
+ # travel-brochure cliché actually takes ("nestled in the hills").
430
+ pattern: /\b(?:boasts\s+a\b|vibrant|nestled\b(?:\s+\S+){0,2}?\s+(?:in|among|between)\b|
222
431
  in\s+the\s+heart\s+of\s+(?:the\s+\w+|downtown\b|(?-i:[A-Z])\w+)|
223
432
  groundbreaking|renowned|diverse\s+array|breathtaking|
224
433
  natural\s+beauty|indelible\s+mark|deeply\s+rooted|
@@ -227,7 +436,8 @@ module Sloplint
227
436
  suggestion: "Replace with a concrete, specific detail or cut it.",
228
437
  examples_bad: [
229
438
  "The vibrant city, nestled in the heart of the valley.",
230
- "A boutique hotel in the heart of Paris."
439
+ "A boutique hotel in the heart of Paris.",
440
+ "A cottage nestled snugly among the pines."
231
441
  ],
232
442
  examples_ok: [
233
443
  "The city sits at the north end of the valley.",
@@ -235,7 +445,12 @@ module Sloplint
235
445
  # place-description cliché — the narrow trigger requires a definite
236
446
  # or proper noun object, which this lacks.
237
447
  "You cannot sit motionless in the heart of these perils.",
238
- "helpless Ahab, even in the heart of such a whirlpool as that"
448
+ "helpless Ahab, even in the heart of such a whirlpool as that",
449
+ # Austen, Emma: "nestled" as the physical verb, not the scene-setting
450
+ # adjective -- no following in/among/between, so the narrowed trigger
451
+ # leaves it alone.
452
+ "He had nestled down his head most conveniently.",
453
+ "The kitten nestled into the blanket."
239
454
  ],
240
455
  rationale: "Travel-brochure adjectives and phrases that models reach for and careful " \
241
456
  "writers avoid."
@@ -266,12 +481,50 @@ module Sloplint
266
481
  id: "underscores-highlights",
267
482
  category: "puffery",
268
483
  severity: "info",
269
- pattern: /\b(?:underscores|highlights|emphasizes)\s+(?:its|the|their)\s+(?:importance|significance)\b/i,
270
- message: '"underscores/highlights its importance" is stock AI framing.',
484
+ # "underscored/underscoring" are unambiguously the verb and flag bare.
485
+ # "underscore/underscores" is also the character noun (a leading
486
+ # underscore, snake_case docs), so those forms require a following
487
+ # determiner/wh-word -- the frame the emphasis verb takes, which the
488
+ # noun never precedes. That guard also skips bare-noun objects
489
+ # ("underscores concerns about"), an accepted miss: widening the frame
490
+ # to catch them readmits the noun sense. Token gaps cross a
491
+ # hard-wrapped line but never a paragraph break. highlights/emphasizes
492
+ # stay narrowed to the importance/significance frame -- both verbs are
493
+ # too common in ordinary prose to match broadly.
494
+ pattern: /\bunderscor(?:ed|ing)\b
495
+ |\bunderscores?(?:[ \t]|\r?\n(?![ \t]*\r?\n))+
496
+ (?:a|an|the|its|his|her|their|our|your|my|this|that|these|those|what|why|how|just)\b
497
+ |\b(?:highlights|emphasizes)\s+(?:its|the|their)\s+(?:importance|significance)\b/ix,
498
+ message: '"underscores" as emphasis (or "highlights/emphasizes its importance") is stock AI framing.',
271
499
  suggestion: "Show why it matters rather than asserting that it does.",
272
- examples_bad: ["This underscores its importance to the field."],
273
- examples_ok: ["She highlights the key line in yellow."],
274
- rationale: "The 'underscores its importance' move asserts significance without earning it."
500
+ examples_bad: [
501
+ "This underscores its importance to the field.",
502
+ "The delay underscores the need for better tooling.",
503
+ "The outage underscored how fragile the pipeline was.",
504
+ "Underscoring the urgency, the board met twice.",
505
+ "The results underscore a deeper problem with the method.",
506
+ "The report underscores just how far behind we are.",
507
+ "It underscores that the market has moved on.",
508
+ "The essay highlights its importance at length.",
509
+ "This emphasizes the significance of early testing."
510
+ ],
511
+ examples_ok: [
512
+ "Replace each space with an underscore.",
513
+ "Variable names use underscores instead of dashes.",
514
+ "The underscore character separates words in snake case.",
515
+ "Prefix private methods with a leading underscore.",
516
+ "Ruby numeric literals accept underscores for readability.",
517
+ "Two underscores mark a dunder method in Python.",
518
+ "She highlights the key line in yellow.",
519
+ "The paper emphasizes the method, not the results.",
520
+ # A paragraph break never welds the noun onto the next sentence.
521
+ "Numbers accept underscores\n\nThe next section covers floats."
522
+ ],
523
+ rationale: "Models reach for 'underscore' as an all-purpose emphasis verb -- findings " \
524
+ "underscore, outages underscore -- asserting significance without earning it. " \
525
+ "Sincere journalistic and academic use exists, hence info: a flag means the " \
526
+ "move is present, not that it's slop. The character noun never takes the " \
527
+ "verb's frame and stays out."
275
528
  ),
276
529
  Rule.new(
277
530
  id: "rich-tapestry",
@@ -290,14 +543,29 @@ module Sloplint
290
543
  id: "not-just-x-but-y",
291
544
  category: "structure",
292
545
  severity: "warning",
546
+ # Two branches, both anchored on an explicit escalation word. (1) The
547
+ # copula escalation: "is not just/only/merely/simply/solely A … but B".
548
+ # (2) "not because A, but because B". The escalation word is what makes
549
+ # these safe to ship at warning -- it is a deliberate authorial move, not
550
+ # a shape ordinary prose falls into. The bare corrective without it
551
+ # ("is not A but B") is a separate, quieter rule: not-x-but-y.
552
+ #
553
+ # Interior spans stop at a paragraph break so a heading or list item
554
+ # cannot join up with the next paragraph's "But …".
293
555
  pattern: /(?:\bis|\bare|\bwas|\bwere|\bisn['’]t|\baren['’]t|\bwasn['’]t|\bweren['’]t|
294
556
  \bit['’]s|\bthat['’]s|\bthis\s+is|\bthese\s+are|\bthose\s+are)
295
- \s+not\s+(?:just|only)\b[^.!?]*?\bbut\b(?:\s+also\b)?/ix,
296
- message: '"X is not just A, but B" is a stock AI escalation structure.',
557
+ \s+not\s+(?:just|only|merely|simply|solely)\b
558
+ (?:[^.!?\n]|\n(?!\s*\n))*?\bbut\b(?:\s+also\b)?
559
+ |
560
+ \bnot\s+because\b(?:[^.!?;\n]|\n(?!\s*\n)){1,60}?\bbut\s+because\b/ix,
561
+ message: '"not just A, but B" is a stock AI escalation structure.',
297
562
  suggestion: "Make the claim once; drop the 'not just… but' frame.",
298
563
  examples_bad: [
299
564
  "It's not just fast, but genuinely reliable.",
300
- "This is not just a tool, but a partner in your workflow."
565
+ "This is not just a tool, but a partner in your workflow.",
566
+ "It is not merely fast but reliable.",
567
+ "The issue is not solely technical but cultural.",
568
+ "She stayed not because it was easy, but because it was hers."
301
569
  ],
302
570
  examples_ok: [
303
571
  "He is not tired.",
@@ -305,12 +573,87 @@ module Sloplint
305
573
  # phrases, not a copula predicating two things of one subject.
306
574
  "This power ought not only to be established, but ought to be established.",
307
575
  "Not only that, but they looked embarrassed.",
308
- "not just to acquire users, but to build something people love."
576
+ "not just to acquire users, but to build something people love.",
577
+ # A paragraph break ends the span; the next paragraph's "but" is not B.
578
+ "He left not because of the noise\n\nbut because of the smell."
309
579
  ],
310
580
  rationale: "'X is not just A, but B' predicates two things of the same subject through a " \
311
581
  "copula, which is the specific shape models overuse. Correlative 'not only… " \
312
582
  "but' joining two verb phrases or clauses, without a preceding copula, is " \
313
- "ordinary and common in formal human prose."
583
+ "ordinary and common in formal human prose; requiring the escalation word " \
584
+ "keeps those out. The same logic covers 'not because A, but because B': the " \
585
+ "escalation word is what marks it deliberate."
586
+ ),
587
+ Rule.new(
588
+ id: "not-x-but-y",
589
+ category: "structure",
590
+ severity: "info",
591
+ # The bare corrective: "is not A but B", no escalation word, comma or no
592
+ # comma. Ships at info because the line between a corrective ("not an
593
+ # accident but a strategy") and an ordinary concession ("not warm but the
594
+ # fire helped") is syntactic, and a regex cannot see syntax. What is here
595
+ # is a set of cheap narrowings that cut the worst of the noise: A is
596
+ # capped at one word after an optional article, "so" is excluded to spare
597
+ # the archaic "not so deep but that", degree words ("quite", "very") are
598
+ # excluded because they open concessives, and B may not be a pronoun,
599
+ # possessive, demonstrative, auxiliary, or quantifier.
600
+ #
601
+ # Those guards are a filter, not a decision procedure. They still let
602
+ # through a concession whose second clause opens with a noun phrase
603
+ # ("was not warm but the fire helped") or a bare lexical verb ("was not
604
+ # perfect but got us there"), because neither is distinguishable from the
605
+ # corrective by surface form. That is the cost of the rule and the reason
606
+ # it is info: the agent reading the flag has the context to judge, and
607
+ # should. Do not chase these by growing the B-list -- every word added
608
+ # silently narrows recall with nothing pinning it.
609
+ pattern: /(?:\bis|\bare|\bwas|\bwere|\bisn['’]t|\baren['’]t|\bwasn['’]t|\bweren['’]t|
610
+ \bit['’]s|\bthat['’]s|\bthis\s+is|\bthese\s+are|\bthose\s+are)
611
+ \s+not\s+
612
+ (?!so\b|just\b|only\b|merely\b|simply\b|solely\b|even\b|yet\b|quite\b|very\b
613
+ |too\b|all\b|always\b|enough\b)
614
+ (?:a\s+|an\s+|the\s+)?[\w'’-]+
615
+ ,?(?:[ \t]|\r?\n(?!\s*\n))+but(?:[ \t]|\r?\n(?!\s*\n))+
616
+ (?:rather(?:[ \t]|\r?\n(?!\s*\n))+)?
617
+ (?!also\b|that\b|this\b|these\b|those\b|they\b|it\b|he\b|she\b|we\b|you\b|i\b
618
+ |his\b|her\b|their\b|its\b|my\b|your\b|our\b|there\b|then\b|still\b|now\b
619
+ |is\b|was\b|are\b|were\b|has\b|had\b|have\b|will\b|would\b|could\b|should\b
620
+ |may\b|might\b|must\b|can\b|to\b|as\b|if\b|when\b|because\b|not\b|no\b|never\b
621
+ |nor\b|neither\b|every\w*\b|nobody\b|none\b|some\b|somebody\b|someone\b
622
+ |anyone\b|anybody\b|anything\b|nothing\b|many\b|most\b|few\b)
623
+ (?:a\s+|an\s+|the\s+)?[\w'’-]+/ix,
624
+ message: '"not A but B" is the bare AI corrective frame.',
625
+ suggestion: "Make the claim once; drop the 'not… but' frame.",
626
+ examples_bad: [
627
+ "The delay was not an accident but a strategy.",
628
+ "The delay was not an accident, but a strategy.",
629
+ "The problem is not misconduct but tone.",
630
+ "It's not a bug but a feature.",
631
+ "That's not an accident but a strategy."
632
+ ],
633
+ examples_ok: [
634
+ # Comma concessive with a pronoun subject: a contrast, not a correction.
635
+ "He was not handsome, but he was kind.",
636
+ # B-side pronoun: concession that continues the sentence.
637
+ "The results are not conclusive but they point in the right direction.",
638
+ # Archaic "not so X but that", common in 19th-century prose.
639
+ "The stream is not so deep but that we may ford it.",
640
+ # A capped at one word: multi-word predicates stay unflagged.
641
+ "The evening was not particularly warm but everyone stayed late.",
642
+ # B-side possessive, with the comma allowed.
643
+ "The house was not large, but its garden ran clear to the river.",
644
+ # Bare "every" on the B side, not just "everyone"/"everything".
645
+ "It is not a bug but every case differs.",
646
+ # A paragraph break ends the frame.
647
+ "The result was not final\n\nBut the team moved on anyway."
648
+ ],
649
+ rationale: "The bare 'is not A but B' corrective is the 'not just… but' move with the " \
650
+ "escalation word dropped, and models reach for it constantly. It is the " \
651
+ "noisiest rule in the catalog by design: a loose version scored 174 hits " \
652
+ "before narrowing, cut to 15 here -- but not all 15 are correctives. Some " \
653
+ "are concessions with an elided subject (Walden's " \
654
+ "'It was not lonely, but made all the earth lonely beneath it'), which no " \
655
+ "surface pattern can tell apart from the real thing. Hence info: a flag here " \
656
+ "means 'this has the shape', not 'this is slop'."
314
657
  ),
315
658
  Rule.new(
316
659
  id: "rule-of-three",
@@ -335,9 +678,29 @@ module Sloplint
335
678
  # expensive rule in the catalog at 65% of scan time on 1MB. Per
336
679
  # CLAUDE.md: some tells can't be regexes; this was one.
337
680
  Rule.new(
338
- id: "em-dash-overuse",
681
+ id: "em-dash",
339
682
  category: "structure",
340
683
  severity: "info",
684
+ pattern: /—/,
685
+ message: "Em dash — an AI punctuation tell.",
686
+ suggestion: "Recast with a comma, parentheses, or a separate sentence.",
687
+ examples_bad: [
688
+ "It was — surprisingly — the best option.",
689
+ "The fix is simple — do less."
690
+ ],
691
+ examples_ok: [
692
+ # Hyphen in a compound modifier -- not the U+2014 this rule targets.
693
+ "The state-of-the-art model shipped on time.",
694
+ # En dash (U+2013) in a range: a different character entirely.
695
+ "See pages 12–18 for the full account."
696
+ ],
697
+ rationale: "Models reach for the em dash by default; humans use it too, but far less " \
698
+ "often."
699
+ ),
700
+ Rule.new(
701
+ id: "em-dash-overuse",
702
+ category: "structure",
703
+ severity: "warning",
341
704
  pattern: /—(?:[^\n]|\n(?!\s*\n))*—(?:[^\n]|\n(?!\s*\n))*—/,
342
705
  message: "Three or more em dashes in one paragraph — an AI punctuation tell.",
343
706
  suggestion: "Recast with commas, parentheses, or separate sentences.",
@@ -353,8 +716,8 @@ module Sloplint
353
716
  # paragraph, even though the raw text has four dashes total.
354
717
  "It was — I think — a fine choice.\n\nAnother option — entirely separate — came up too."
355
718
  ],
356
- rationale: "Heavy em-dash interjection is a strong model tell; two is fine, three signals " \
357
- "slop."
719
+ rationale: "Three or more em dashes packed into one paragraph is a denser interjection " \
720
+ "habit than most human writing settles into."
358
721
  ),
359
722
 
360
723
  # ── hedging ───────────────────────────────────────────────────────────
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sloplint
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sloplint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Jackson
@@ -81,7 +81,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
81
81
  - !ruby/object:Gem::Version
82
82
  version: '0'
83
83
  requirements: []
84
- rubygems_version: 3.7.2
84
+ rubygems_version: 4.0.17
85
85
  specification_version: 4
86
86
  summary: Flag the rhetorical tics and puffery that mark AI-generated prose.
87
87
  test_files: []