sloplint 0.1.0 → 0.2.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: 846ce6e663bb01d1dd3e054f328eaccb423745aeb34def94b8ce33253d960c23
4
+ data.tar.gz: f165880bb800c66a57d88ffd7e9876c4426a7607a6f4b68582d8f0365c728956
5
5
  SHA512:
6
- metadata.gz: 607445e1bdb23eacb5d460b01e72d1ebb140136974c7988ebdd023c7db4cccfd4540e1e4ddb68031938fe936d73d173c52d0818d3573ea045cbbc0b7db46696c
7
- data.tar.gz: 55faafabedfbde617ea5cfed7e3ac89a3e7e82f24521346b21b6ddc3fe0a775e41dd1bfeacc5860326fcfaa7ab006316a9b9ab6b09fbe956678cf6de172a71c5
6
+ metadata.gz: ef1d295149a0b032fc000795316d385331a679aa246dcf4177c902a4589745e91f63d728e7a5b6ceb0c5323902c3079b1e588580f5bbbc08e07f9a19d2560a00
7
+ data.tar.gz: bd0530c68b5dac9587580b79fffdd52bfed59ab847c18ecd1e3a164c906cd6f745cb7409278283876b3dcdf5ba7309459b422e047cc73c2406984569b6215bfa
data/CHANGELOG.md CHANGED
@@ -3,7 +3,52 @@
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
+ ## [0.2.0] - 2026-07-28
7
+
8
+ - `is-real-and-not` and `worth-naming` move from `warning` to `info`. Both
9
+ patterns match on surface form alone and can't tell the AI cadence from an
10
+ unrelated sentence that happens to share it: "is real, and/but/not" fires on
11
+ any predicate-adjective sentence regardless of what follows the conjunction
12
+ ("my debt to my senses is real and constant" -- Emerson), and "worth naming"
13
+ collapses the AI meta-signpost with the plain sense of a thing worth
14
+ mentioning ("the only thing worth naming to do that" -- Emerson). Probed
15
+ against a 1.06M-word corpus (Austen, Melville, Madison, Thoreau, Emerson);
16
+ the other 19 rules still at `warning` were probed too and held up clean.
17
+ - `puffery-words`' bare `nestled` matched the literal verb as often as the
18
+ puffery sense -- a head nestled against a shoulder, a kitten nestled into a
19
+ blanket. It now requires a following in/among/between, the shape the
20
+ travel-brochure cliché actually takes ("nestled in the hills").
21
+ - New rule `not-x-but-y` (`structure`, `info`): the bare corrective "is not A
22
+ but B" with no escalation word, comma before "but" or none. Ships at `info`
23
+ because the line between a corrective and an ordinary concession is
24
+ syntactic, and a pattern can only approximate it — a concession with an
25
+ elided subject ("was not perfect but got us there") still gets through.
26
+ - New rule `no-x-no-y-frag` (`rhetorical-tic`, `info`): the "no X, no Y"
27
+ cadence built from sentence fragments ("No fluff. No filler.") rather than
28
+ commas. `info`, not `warning`, because two short "no" sentences in a row is
29
+ also just writing.
30
+ - `not-just-x-but-y` now also catches "not because A, but because B" and the
31
+ escalation words `merely`, `simply`, and `solely` alongside `just`/`only`.
32
+ It keeps `warning`: the escalation word is a deliberate authorial move.
33
+ - Both `not-…-but-…` rules now stop at a paragraph break, so an unpunctuated
34
+ heading or list item no longer joins up with the next paragraph's "But …".
35
+ - `no-x-no-y-frag` links must begin a sentence, so an ordinary sentence can no
36
+ longer donate its tail to a chain ("There was no bread. No milk either." is
37
+ one sentence and one fragment, not a chain), and the link separator is
38
+ capped at two spaces so a code span blanked by `--markdown` cannot weld two
39
+ distant fragments together.
40
+ - `sloplint explain` no longer breaks its aligned fixture block when a fixture
41
+ contains a newline; those are escaped as `\n`.
42
+ - Notes carry a new `context` field: the match bracketed inside ~40 characters
43
+ of surrounding prose. Human output shows it in place of the bare match, which
44
+ told you nothing when the match was a single word or a lone em dash. `excerpt`
45
+ is unchanged and still the bare match.
46
+ - New rule `em-dash` (`structure`, `info`): flags every em dash, not just
47
+ paragraphs dense with them.
48
+ - `em-dash-overuse` (3+ em dashes in one paragraph) is now `warning`, up from
49
+ `info`.
50
+
51
+ ## [0.1.0] - 2026-07-24
7
52
 
8
53
  First public release.
9
54
 
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
+ -:2:9: warning exactly-the "exactly the point/kind/problem/…" is an overused LLM emphasis tic.
55
+ excerpt: …tapestry of vibrant detail. That is [exactly the point] I keep making about it.
56
+ why: 'exactly the X' is a model intensifier that a careful writer rarely leans on.
54
57
  fix: Drop 'exactly the'; state the point without the intensifier.
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
+ 27 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** (16) the cadence patterns: `no-x-no-y`, `no-x-no-y-frag`, `thats-the-whole`, `thats-how-x`, `announced-takeaway`, `exactly-the`, `you-already-know`, `sit-with-that`, `thats-not-nothing`, 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,16 +272,16 @@ 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" |
277
+ | `worth-naming` | "worth naming", "it's worth naming that…", "Worth naming:" | skip "naming names"; `info` |
238
278
  | `thats-not-nothing` | "that/this/it/which is not nothing" | |
239
279
 
240
280
  ### puffery (Wikipedia: Signs of AI writing)
241
281
 
242
282
  Single flat rule per word-cluster, matched as whole words:
243
283
 
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.
284
+ - `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
285
  - `stands-serves-as` — "stands as / serves as", "is a testament/reminder to".
246
286
  - `vital-role` — "plays a (vital/crucial/pivotal/significant/key) role".
247
287
  - `underscores-highlights` — "underscores/highlights/emphasizes its (importance/significance)".
@@ -249,9 +289,11 @@ Single flat rule per word-cluster, matched as whole words:
249
289
 
250
290
  ### structure
251
291
 
252
- - `not-just-x-but-y` — "not just X, but (also) Y", "not only but".
292
+ - `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.
293
+ - `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
294
  - `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`.
295
+ - `em-dash` — any em dash; `info`.
296
+ - `em-dash-overuse` — 3+ em dashes in one paragraph; `warning`.
255
297
 
256
298
  ### hedging
257
299
 
@@ -274,7 +316,7 @@ This is a first-class requirement, not an afterthought.
274
316
  # Recommended for agents:
275
317
  cat FILE | sloplint check --markdown -o json -
276
318
  # exit 0 = clean, 1 = notes found, >1 = error
277
- # each note: {path,line,column,severity,rule,message,excerpt,suggestion}
319
+ # each note: {path,line,column,severity,rule,category,message,excerpt,context,rationale,suggestion}
278
320
  ```
279
321
 
280
322
  - Every option has a full-sentence help string (no telegraphic fragments).
@@ -306,3 +348,7 @@ RSpec (dev dependency), run via `rake spec`.
306
348
  - Non-English. Languages other than English are a v2 conversation.
307
349
  - ML/embedding-based detection. This is a regex linter on purpose — fast,
308
350
  explainable, zero-dependency. Statistical detection is a different product.
351
+ - Scraped corpora. Platform terms prohibit automated collection, and finding a
352
+ repeated shape needs hundreds of samples rather than millions, so collection
353
+ stays manual. Analysis is offline development work either way; the shipped
354
+ 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.",
@@ -33,6 +37,51 @@ module Sloplint
33
37
  "Madison, Thoreau, and Emerson combined. A careful writer occasionally stacks " \
34
38
  "two (and, rarely, more), but a model reaches for the pattern constantly."
35
39
  ),
40
+ Rule.new(
41
+ id: "no-x-no-y-frag",
42
+ category: "rhetorical-tic",
43
+ severity: "info",
44
+ # The same cadence built from sentence fragments: "No fluff. No filler."
45
+ # Ships at info, not warning, because the shape is genuinely ambiguous --
46
+ # two short "no" sentences in a row is also just writing ("No one moved.
47
+ # No one spoke."). The agent reading the flag decides; see rationale.
48
+ #
49
+ # Two structural guards keep it from reporting chains that are not in the
50
+ # text at all. A link must START a sentence, so an ordinary sentence
51
+ # cannot donate its tail ("There was no bread." + "No milk either." is
52
+ # one sentence and one fragment, not a chain). And the separator is at
53
+ # most two spaces, so a blanked-out code span or URL under --markdown
54
+ # cannot silently weld two distant fragments together. Links may cross a
55
+ # hard-wrapped newline (\r\n included) but never a paragraph break.
56
+ pattern: /(?:^|(?<=[.;!?])[ \t]{1,2})\K
57
+ no[ \t]+[\w'-]+(?:[ \t]+[\w'-]+)?[ \t]*[.;!]
58
+ (?:(?:[ \t]{1,2}|\r?\n(?!\s*\n)[ \t]*)
59
+ (?:and[ \t]+)?no[ \t]+[\w'-]+(?:[ \t]+[\w'-]+)?[ \t]*[.;!])+/ix,
60
+ message: '"No X. No Y." fragment chain (%{count} items) reads as AI cadence.',
61
+ suggestion: "Cut the chain or make it one plain sentence.",
62
+ count_group: /\bno\b/i,
63
+ examples_bad: [
64
+ "No fluff. No filler. No jargon.",
65
+ "No fees; no contracts; no hidden charges.",
66
+ # Hard-wrapped Markdown: the chain survives one newline.
67
+ "No fluff.\nNo filler."
68
+ ],
69
+ examples_ok: [
70
+ "No parking on Sundays.",
71
+ # A link must start a sentence, so this donates no tail.
72
+ "There was no bread. No milk either.",
73
+ "Say no more. No worries.",
74
+ # Two fragments split by a paragraph break never chain.
75
+ "No answer.\n\nNo one was home when we finally arrived."
76
+ ],
77
+ rationale: "The fragment form of the same cadence, and the weaker signal of the two: the " \
78
+ "comma chain is one authored sentence, while this is just short sentences in " \
79
+ "sequence, which human prose also does. It scored 1 hit in a 744k-word " \
80
+ "five-book probe (Hamilton's 'no common coin; no common judicatory', itself " \
81
+ "the chain in question), so it is rare in careful writing -- but a deliberate " \
82
+ "staccato run ('No one moved. No one spoke.') has the identical shape and is " \
83
+ "not a tell. Treat a flag here as a question, not a verdict."
84
+ ),
36
85
  Rule.new(
37
86
  id: "thats-the-whole",
38
87
  category: "rhetorical-tic",
@@ -114,7 +163,7 @@ module Sloplint
114
163
  Rule.new(
115
164
  id: "is-real-and-not",
116
165
  category: "rhetorical-tic",
117
- severity: "warning",
166
+ severity: "info",
118
167
  pattern: /\bis\s+real,?\s+(?:and|but|not)\b/i,
119
168
  message: '"The X is real, and…" is a stock LLM concession move.',
120
169
  suggestion: "Drop the 'is real, and' scaffolding; assert the point directly.",
@@ -126,7 +175,12 @@ module Sloplint
126
175
  # phrasing it could plausibly have been guarding against.
127
176
  examples_bad: ["The risk is real, and it is growing."],
128
177
  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."
178
+ rationale: "The pattern requires nothing about what follows the conjunction, so it fires " \
179
+ "on any 'is real' sentence that happens to continue with and/but/not, concession " \
180
+ "or not. Emerson's 'my debt to my senses is real and constant' is two predicate " \
181
+ "adjectives, not a both-sidesing move -- the AI cadence and the plain sentence " \
182
+ "are three words apart and identical on the surface. An agent reading the flag " \
183
+ "has the rest of the sentence to judge; the pattern alone doesn't."
130
184
  ),
131
185
  Rule.new(
132
186
  id: "the-punchline-is",
@@ -142,7 +196,7 @@ module Sloplint
142
196
  Rule.new(
143
197
  id: "worth-naming",
144
198
  category: "rhetorical-tic",
145
- severity: "warning",
199
+ severity: "info",
146
200
  # Widened to optionally include a trailing "names" so the "naming
147
201
  # names" idiom is part of the matched text -- skip: checks the matched
148
202
  # text itself, and the tighter /\bworth\s+naming\b/ never captured
@@ -153,7 +207,12 @@ module Sloplint
153
207
  skip: [/naming names/i],
154
208
  examples_bad: ["One tension is worth naming here."],
155
209
  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."
210
+ rationale: "'worth naming' collapses two senses a regex can't tell apart: the AI " \
211
+ "meta-signpost announcing a point is coming ('One tension is worth naming " \
212
+ "here') and the plain sense of a thing worth calling or mentioning, which " \
213
+ "careful writers use too -- Emerson's 'the only thing worth naming to do that' " \
214
+ "is the latter, not the former. A flag here means the phrase is present, not " \
215
+ "which sense it's in."
157
216
  ),
158
217
  Rule.new(
159
218
  id: "thats-not-nothing",
@@ -218,7 +277,11 @@ module Sloplint
218
277
  id: "puffery-words",
219
278
  category: "puffery",
220
279
  severity: "warning",
221
- pattern: /\b(?:boasts\s+a\b|vibrant|nestled|
280
+ # "nestled" alone is the literal verb as often as the puffery sense --
281
+ # a head nestling against a shoulder, a kitten nestling into a blanket
282
+ # -- so it requires a following in/among/between, the same shape the
283
+ # travel-brochure cliché actually takes ("nestled in the hills").
284
+ pattern: /\b(?:boasts\s+a\b|vibrant|nestled\b(?:\s+\S+){0,2}?\s+(?:in|among|between)\b|
222
285
  in\s+the\s+heart\s+of\s+(?:the\s+\w+|downtown\b|(?-i:[A-Z])\w+)|
223
286
  groundbreaking|renowned|diverse\s+array|breathtaking|
224
287
  natural\s+beauty|indelible\s+mark|deeply\s+rooted|
@@ -227,7 +290,8 @@ module Sloplint
227
290
  suggestion: "Replace with a concrete, specific detail or cut it.",
228
291
  examples_bad: [
229
292
  "The vibrant city, nestled in the heart of the valley.",
230
- "A boutique hotel in the heart of Paris."
293
+ "A boutique hotel in the heart of Paris.",
294
+ "A cottage nestled snugly among the pines."
231
295
  ],
232
296
  examples_ok: [
233
297
  "The city sits at the north end of the valley.",
@@ -235,7 +299,12 @@ module Sloplint
235
299
  # place-description cliché — the narrow trigger requires a definite
236
300
  # or proper noun object, which this lacks.
237
301
  "You cannot sit motionless in the heart of these perils.",
238
- "helpless Ahab, even in the heart of such a whirlpool as that"
302
+ "helpless Ahab, even in the heart of such a whirlpool as that",
303
+ # Austen, Emma: "nestled" as the physical verb, not the scene-setting
304
+ # adjective -- no following in/among/between, so the narrowed trigger
305
+ # leaves it alone.
306
+ "He had nestled down his head most conveniently.",
307
+ "The kitten nestled into the blanket."
239
308
  ],
240
309
  rationale: "Travel-brochure adjectives and phrases that models reach for and careful " \
241
310
  "writers avoid."
@@ -290,14 +359,29 @@ module Sloplint
290
359
  id: "not-just-x-but-y",
291
360
  category: "structure",
292
361
  severity: "warning",
362
+ # Two branches, both anchored on an explicit escalation word. (1) The
363
+ # copula escalation: "is not just/only/merely/simply/solely A … but B".
364
+ # (2) "not because A, but because B". The escalation word is what makes
365
+ # these safe to ship at warning -- it is a deliberate authorial move, not
366
+ # a shape ordinary prose falls into. The bare corrective without it
367
+ # ("is not A but B") is a separate, quieter rule: not-x-but-y.
368
+ #
369
+ # Interior spans stop at a paragraph break so a heading or list item
370
+ # cannot join up with the next paragraph's "But …".
293
371
  pattern: /(?:\bis|\bare|\bwas|\bwere|\bisn['’]t|\baren['’]t|\bwasn['’]t|\bweren['’]t|
294
372
  \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.',
373
+ \s+not\s+(?:just|only|merely|simply|solely)\b
374
+ (?:[^.!?\n]|\n(?!\s*\n))*?\bbut\b(?:\s+also\b)?
375
+ |
376
+ \bnot\s+because\b(?:[^.!?;\n]|\n(?!\s*\n)){1,60}?\bbut\s+because\b/ix,
377
+ message: '"not just A, but B" is a stock AI escalation structure.',
297
378
  suggestion: "Make the claim once; drop the 'not just… but' frame.",
298
379
  examples_bad: [
299
380
  "It's not just fast, but genuinely reliable.",
300
- "This is not just a tool, but a partner in your workflow."
381
+ "This is not just a tool, but a partner in your workflow.",
382
+ "It is not merely fast but reliable.",
383
+ "The issue is not solely technical but cultural.",
384
+ "She stayed not because it was easy, but because it was hers."
301
385
  ],
302
386
  examples_ok: [
303
387
  "He is not tired.",
@@ -305,12 +389,87 @@ module Sloplint
305
389
  # phrases, not a copula predicating two things of one subject.
306
390
  "This power ought not only to be established, but ought to be established.",
307
391
  "Not only that, but they looked embarrassed.",
308
- "not just to acquire users, but to build something people love."
392
+ "not just to acquire users, but to build something people love.",
393
+ # A paragraph break ends the span; the next paragraph's "but" is not B.
394
+ "He left not because of the noise\n\nbut because of the smell."
309
395
  ],
310
396
  rationale: "'X is not just A, but B' predicates two things of the same subject through a " \
311
397
  "copula, which is the specific shape models overuse. Correlative 'not only… " \
312
398
  "but' joining two verb phrases or clauses, without a preceding copula, is " \
313
- "ordinary and common in formal human prose."
399
+ "ordinary and common in formal human prose; requiring the escalation word " \
400
+ "keeps those out. 'not because… but because' scored 2 hits in a 744k-word " \
401
+ "five-book probe (Austen, Melville, Hamilton/Madison, Thoreau, Emerson)."
402
+ ),
403
+ Rule.new(
404
+ id: "not-x-but-y",
405
+ category: "structure",
406
+ severity: "info",
407
+ # The bare corrective: "is not A but B", no escalation word, comma or no
408
+ # comma. Ships at info because the line between a corrective ("not an
409
+ # accident but a strategy") and an ordinary concession ("not warm but the
410
+ # fire helped") is syntactic, and a regex cannot see syntax. What is here
411
+ # is a set of cheap narrowings that cut the worst of the noise: A is
412
+ # capped at one word after an optional article, "so" is excluded to spare
413
+ # the archaic "not so deep but that", degree words ("quite", "very") are
414
+ # excluded because they open concessives, and B may not be a pronoun,
415
+ # possessive, demonstrative, auxiliary, or quantifier.
416
+ #
417
+ # Those guards are a filter, not a decision procedure. They still let
418
+ # through a concession whose second clause opens with a noun phrase
419
+ # ("was not warm but the fire helped") or a bare lexical verb ("was not
420
+ # perfect but got us there"), because neither is distinguishable from the
421
+ # corrective by surface form. That is the cost of the rule and the reason
422
+ # it is info: the agent reading the flag has the context to judge, and
423
+ # should. Do not chase these by growing the B-list -- every word added
424
+ # silently narrows recall with nothing pinning it.
425
+ pattern: /(?:\bis|\bare|\bwas|\bwere|\bisn['’]t|\baren['’]t|\bwasn['’]t|\bweren['’]t|
426
+ \bit['’]s|\bthat['’]s|\bthis\s+is|\bthese\s+are|\bthose\s+are)
427
+ \s+not\s+
428
+ (?!so\b|just\b|only\b|merely\b|simply\b|solely\b|even\b|yet\b|quite\b|very\b
429
+ |too\b|all\b|always\b|enough\b)
430
+ (?:a\s+|an\s+|the\s+)?[\w'’-]+
431
+ ,?(?:[ \t]|\r?\n(?!\s*\n))+but(?:[ \t]|\r?\n(?!\s*\n))+
432
+ (?:rather(?:[ \t]|\r?\n(?!\s*\n))+)?
433
+ (?!also\b|that\b|this\b|these\b|those\b|they\b|it\b|he\b|she\b|we\b|you\b|i\b
434
+ |his\b|her\b|their\b|its\b|my\b|your\b|our\b|there\b|then\b|still\b|now\b
435
+ |is\b|was\b|are\b|were\b|has\b|had\b|have\b|will\b|would\b|could\b|should\b
436
+ |may\b|might\b|must\b|can\b|to\b|as\b|if\b|when\b|because\b|not\b|no\b|never\b
437
+ |nor\b|neither\b|every\w*\b|nobody\b|none\b|some\b|somebody\b|someone\b
438
+ |anyone\b|anybody\b|anything\b|nothing\b|many\b|most\b|few\b)
439
+ (?:a\s+|an\s+|the\s+)?[\w'’-]+/ix,
440
+ message: '"not A but B" is the bare AI corrective frame.',
441
+ suggestion: "Make the claim once; drop the 'not… but' frame.",
442
+ examples_bad: [
443
+ "The delay was not an accident but a strategy.",
444
+ "The delay was not an accident, but a strategy.",
445
+ "The problem is not misconduct but tone.",
446
+ "It's not a bug but a feature.",
447
+ "That's not an accident but a strategy."
448
+ ],
449
+ examples_ok: [
450
+ # Comma concessive with a pronoun subject: a contrast, not a correction.
451
+ "He was not handsome, but he was kind.",
452
+ # B-side pronoun: concession that continues the sentence.
453
+ "The results are not conclusive but they point in the right direction.",
454
+ # Archaic "not so X but that", common in 19th-century prose.
455
+ "The stream is not so deep but that we may ford it.",
456
+ # A capped at one word: multi-word predicates stay unflagged.
457
+ "The evening was not particularly warm but everyone stayed late.",
458
+ # B-side possessive, with the comma allowed.
459
+ "The house was not large, but its garden ran clear to the river.",
460
+ # Bare "every" on the B side, not just "everyone"/"everything".
461
+ "It is not a bug but every case differs.",
462
+ # A paragraph break ends the frame.
463
+ "The result was not final\n\nBut the team moved on anyway."
464
+ ],
465
+ rationale: "The bare 'is not A but B' corrective is the 'not just… but' move with the " \
466
+ "escalation word dropped, and models reach for it constantly. It is the " \
467
+ "noisiest rule in the catalog by design: a loose version scored 174 hits in a " \
468
+ "744k-word five-book probe, and the narrowings here cut that to 15 -- but not " \
469
+ "all 15 are correctives. Some are concessions with an elided subject (Walden's " \
470
+ "'It was not lonely, but made all the earth lonely beneath it'), which no " \
471
+ "surface pattern can tell apart from the real thing. Hence info: a flag here " \
472
+ "means 'this has the shape', not 'this is slop'."
314
473
  ),
315
474
  Rule.new(
316
475
  id: "rule-of-three",
@@ -335,9 +494,29 @@ module Sloplint
335
494
  # expensive rule in the catalog at 65% of scan time on 1MB. Per
336
495
  # CLAUDE.md: some tells can't be regexes; this was one.
337
496
  Rule.new(
338
- id: "em-dash-overuse",
497
+ id: "em-dash",
339
498
  category: "structure",
340
499
  severity: "info",
500
+ pattern: /—/,
501
+ message: "Em dash — an AI punctuation tell.",
502
+ suggestion: "Recast with a comma, parentheses, or a separate sentence.",
503
+ examples_bad: [
504
+ "It was — surprisingly — the best option.",
505
+ "The fix is simple — do less."
506
+ ],
507
+ examples_ok: [
508
+ # Hyphen in a compound modifier -- not the U+2014 this rule targets.
509
+ "The state-of-the-art model shipped on time.",
510
+ # En dash (U+2013) in a range: a different character entirely.
511
+ "See pages 12–18 for the full account."
512
+ ],
513
+ rationale: "Models reach for the em dash by default; humans use it too, but far less " \
514
+ "often."
515
+ ),
516
+ Rule.new(
517
+ id: "em-dash-overuse",
518
+ category: "structure",
519
+ severity: "warning",
341
520
  pattern: /—(?:[^\n]|\n(?!\s*\n))*—(?:[^\n]|\n(?!\s*\n))*—/,
342
521
  message: "Three or more em dashes in one paragraph — an AI punctuation tell.",
343
522
  suggestion: "Recast with commas, parentheses, or separate sentences.",
@@ -353,8 +532,8 @@ module Sloplint
353
532
  # paragraph, even though the raw text has four dashes total.
354
533
  "It was — I think — a fine choice.\n\nAnother option — entirely separate — came up too."
355
534
  ],
356
- rationale: "Heavy em-dash interjection is a strong model tell; two is fine, three signals " \
357
- "slop."
535
+ rationale: "Three or more em dashes packed into one paragraph is a denser interjection " \
536
+ "habit than most human writing settles into."
358
537
  ),
359
538
 
360
539
  # ── 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.2.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.2.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: []