sloplint 0.5.0 → 0.6.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: 74566267ccb2ee1d4caec464d3098809e24f232f75872a7df2ac61ef7d583153
4
- data.tar.gz: 4e3b6196aeb6604d212d00be14032e530a644d4250820424774244fe7298fa9e
3
+ metadata.gz: d0d26e3bebafd7184f09aa41f70d1013e8ca6d9f460d4e0229815c18db08263a
4
+ data.tar.gz: 944923cfab5a213d08a5367bae1dfd75fb4fc71aa9a004fbb59806b5dace1376
5
5
  SHA512:
6
- metadata.gz: 41e707ec52643d3f57abe04867d399fef77c8e567378394e4c1e0e2ca541d8aefaa2cb908b9d0e419fc9af9f0a1aedb55adce9ef87fc187628be4a90641a4809
7
- data.tar.gz: ed1b87c4a32490d582100343d2e7a4b114067737de3761fa3fb768e669f398e69b8c4e3ce4cb33d3138702d533818f5dd53b2b778d8736f848ca71c8166e61f8
6
+ metadata.gz: 19ad341fcb7a415b737bc4e1fdd3f355d4b6bd91429209477b4ac4605a2f18b7372bf1791e34e9e0e69b6d50b17a02abf959981b7bd32b5345a30ebc3151cb87
7
+ data.tar.gz: 9f0732fe535cc7753ed2b8380687575c4b109b5f9be31409e1d207c9ccb3b7ffbcb69a833427e68bb73c905369b47dd920ce7dd7162e456a61dd7a07c468c853
data/CHANGELOG.md CHANGED
@@ -3,7 +3,269 @@
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
- ## [Unreleased]
6
+ ## [0.6.0] - 2026-09-08
7
+
8
+ The catalog grows from 50 rules to 75, and sloplint ships as a Claude Code
9
+ plugin as well as a gem.
10
+
11
+ ### Added
12
+
13
+ - sloplint now ships as a Claude Code plugin as well as a gem. Add the
14
+ marketplace `benjaminjackson/sloplint` and install `sloplint`; the plugin
15
+ carries the Ruby source with it, so nothing is installed from a package
16
+ index and it works where `gem install` cannot reach the network — Claude
17
+ Cowork above all. The plugin adds one skill, `/sloplint:check`, which runs
18
+ the scanner and reports what it returns. The skill refuses to assess prose
19
+ on its own when the scanner cannot run, because a review with no scan
20
+ behind it reads exactly like one with a scan behind it.
21
+ - `not-by-x-but-by-y`: the corrective frame on a repeated preposition, "not
22
+ by luck, but by design". The copula rules cannot see it because nothing
23
+ anchors "not" but a verb or a dash, so the anchor is the preposition, which
24
+ must repeat after "but". Ships at `info`: Thoreau and Melville write it too.
25
+
26
+ ### Fixed
27
+
28
+ - `check` now reads files and stdin as UTF-8 whatever the locale says. A
29
+ sandbox with no `LANG` set — Claude Cowork's, among others — leaves Ruby's
30
+ default external encoding at US-ASCII, and the first em dash in a draft then
31
+ failed the whole scan with `invalid input: invalid byte sequence in
32
+ US-ASCII`. Prose is the only input sloplint takes, so UTF-8 is the
33
+ assumption rather than the locale's guess. Genuinely invalid UTF-8 is still
34
+ exit 2.
35
+
36
+ ### Changed
37
+
38
+ - `thats-the-whole` now takes `value` and `fix` after "that's the whole",
39
+ alongside `point`, `game`, `thing`, `deal` and `story`. The contracted
40
+ closer is the one an agent writes in technical prose ("That's the whole
41
+ value of the `info` tier", "That's the whole fix"), and `is-the-whole-x`
42
+ sees only the uncontracted "is", so the sentence slipped both rules. The
43
+ uncontracted "That is the whole fix." moves with it, from `info` under
44
+ `is-the-whole-x` to `warning`. The two new nouns must end the sentence or
45
+ the paragraph, or run into one of the words a closer trails off on, so
46
+ `thats-the-whole` leaves "value chain", "value-add" and "fix list" alone
47
+ however the line wraps (`is-the-whole-x` still reads the uncontracted
48
+ "That is the whole value chain" at `info`, as it did), and a curly
49
+ apostrophe now counts as a contraction. Both rules interpolate one `WHOLE_CLOSERS`
50
+ fragment, so the lists cannot drift, and a spec walks every noun through
51
+ every form the two wrappers treat differently. Neither noun appears after
52
+ "that's the whole" in 1.55M words of public-domain prose.
53
+
54
+ - Empty or whitespace-only input
55
+ is now exit 2 with `empty input: nothing to
56
+ check in ...`, instead of exit 0. Reporting a scan of nothing as a clean
57
+ scan is the same trap a mistyped rule id sets, and gets the same answer. The
58
+ text is tested before `--markdown` blanks code and URLs, so a file holding
59
+ only a fenced code block still exits 0; and only an all-empty set of sources
60
+ is an error, so one empty file among several named ones is left alone.
61
+
62
+ - The executable moved from `bin/sloplint` to `exe/sloplint`. claude.ai
63
+ rejects a plugin with a top-level `bin/` directory, which would block
64
+ organization distribution. `gem install sloplint` is unaffected.
65
+
66
+ - `exe/sloplint` now checks `RUBY_VERSION` before loading anything and aborts
67
+ with an explanation, rather than raising `NoMethodError` from `Data.define`.
68
+ The Ruby macOS ships at `/usr/bin/ruby` is 2.6 and hits this.
69
+
70
+ ### New rules
71
+
72
+ - `from-x-to-y-chain` (rhetorical-tic, warning) flags two or more
73
+ comma-separated "from X to Y" spans in a row: "from guessing to measuring,
74
+ from hoping to knowing". Each span names two poles and nothing between
75
+ them, and stacking them sweeps across a change without describing it. Two
76
+ human shapes are skipped: the relay, where each span starts where the last
77
+ ended ("from the egg to the worm, from the worm to the fly"), and the
78
+ reduplication, where a span has the same word at both ends ("from hummock
79
+ to hummock"). Operands must open with a letter, so a list of ranges ("from
80
+ 1990 to 1995, from 1997 to 2001") is not a chain. With those out it flags once in 2.4M words of pre-2022
81
+ Hacker News (a geographic sweep) and once in 1.25M words of public-domain
82
+ prose (Joyce).
83
+
84
+ - `one-x-one-y` (rhetorical-tic, warning) flags three or more "one X" items
85
+ in a comma chain that stands on its own: "One owner, one repository, one
86
+ weekly prune." The word does no counting; it sets a rhythm. The chain must
87
+ open a sentence or follow a colon, because after a verb ("the flat has one
88
+ bedroom, one bathroom, one balcony") the word is counting. Items are
89
+ letter-led, so an enumeration over numbers is out; the distributive "one
90
+ for you, one for me, one for the pot" is skipped at any length. Nothing in
91
+ 2.4M words of pre-2022 Hacker News or 1.25M words of public-domain prose.
92
+
93
+ - `everyone-nobody` (structure, warning) flags the comma-spliced antithesis
94
+ on quantifier subjects: "Everyone wants the dashboard, nobody maintains
95
+ it." The
96
+ balance is what makes the diagnosis sound settled. The comma splice is
97
+ required (with "and" it is a sentence, with a period it is two), the two
98
+ subjects must differ in polarity, and the second clause must close the
99
+ sentence. Nothing in 2.4M words of pre-2022 Hacker News or 1.25M words of
100
+ public-domain prose.
101
+
102
+ - `and-what-it-should` (rhetorical-tic, warning) flags the elliptical tail:
103
+ "List what the assistant knows about the client, and what it should." The
104
+ second clause borrows its verb from the first and closes on a bare modal
105
+ or a negated auxiliary, so the sentence ends on a contrast it never
106
+ states. The comma, the conjunction, and the full stop right after the
107
+ modal are all required; the affirmative copula and do-verb ("and what he
108
+ does.") are complete clauses and stay out, and so does a question.
109
+ Nothing in 2.4M words of pre-2022 Hacker News or 1.25M words of
110
+ public-domain prose.
111
+
112
+ - `abstract-lives-in` (rhetorical-tic, info) flags an abstraction given an
113
+ address: "the craft that lives between the two desks", "its context lives
114
+ in a folder nobody else can open", "the value sits in the follow-up". The
115
+ subject list is closed and abstract, so people and dogs living and sitting
116
+ places never match, and a capitalised subject is a proper noun. "with" is
117
+ left out of the prepositions because "the decision sits with the board" is
118
+ ordinary English for who is responsible, "at" because "the value sits at
119
+ ten million" is a quantity, and "lies in" because "the problem lies in
120
+ the assumption" is where a fault is. It ships at info because the same
121
+ shape says where information literally is ("the knowledge lives in our
122
+ heads"), and a sample of pre-2022 Hacker News biased toward the
123
+ construction turns up a few of those per million words, all human. A
124
+ draft that keeps giving ideas addresses should be read as a warning.
125
+
126
+ - `np-fragment-and` (structure, info) flags a whole sentence made of two
127
+ noun phrases and an "and": "A named owner and a quarterly review." It is
128
+ the fix half of a model's problem-then-fix pair with the verb left out,
129
+ and its usual habitat is a bulleted list, so a list marker may open it.
130
+ Each phrase is one to three words, and no auxiliary or modal may appear,
131
+ contractions included. It ships at info because a lexical verb is
132
+ invisible to the pattern: "A car and a truck collided." has the same
133
+ shape and flags. That sentence is rare in the corpora (nothing in 2.4M
134
+ words of pre-2022 Hacker News, once in 1.25M words of public-domain
135
+ prose), but it is a sentence, so one flag is a question; a draft full of
136
+ them should be read as a warning.
137
+
138
+ - `the-x-is-the-x` (rhetorical-tic, warning) flags the repeated-head
139
+ equative: "the reason it holds up is the reason the other half happens",
140
+ "the problem with the tool is the problem with the team". A backreference
141
+ catches the same abstract head noun on both sides of the copula, so the
142
+ sentence equates two things while naming neither. The head list holds
143
+ only nouns that cannot name an object, since "the key to the front door
144
+ is the key on the red fob" is an identity statement; the two heads must
145
+ share a clause, so an earlier "the cost was low, but shipping is the
146
+ cost" never pairs; and the second head must be followed by a preposition,
147
+ determiner, quantifier, pronoun, plural noun or punctuation, so "the
148
+ answer key" is not a repeat. Once in 2.4M words of pre-2022 Hacker News;
149
+ nothing in 1.25M words of public-domain prose.
150
+
151
+ - `same-determiner-chain` (rhetorical-tic, info) is the quiet cousin of
152
+ `one-x-one-y`: three or more comma-separated items opening on the same
153
+ determiner or quantifier, "every file, every branch, every deploy", caught
154
+ with a backreference. The narrative possessives (my, his, her, their, its)
155
+ are left out, since "his fame, his position, his life" is every
156
+ novelist's. It ships at info because the device is one humans use on
157
+ purpose: about 8 per million words of pre-2022 Hacker News and 14 per
158
+ million
159
+ in public-domain prose. One is a question; several in a draft should be
160
+ read as a warning.
161
+
162
+ - `quip-question` (structure, info) flags the verbless question that opens
163
+ a pitch: "No invite?", "New to the tool?", "Still stuck?". It must start a
164
+ sentence, open on one of a short list of words, and close on the question
165
+ mark within four more words with no auxiliary or contraction, so a real
166
+ question stays out; "Need" and "Want" are left off the list because "Need
167
+ help?" is a question with its verb elided. Ships at info: about six per
168
+ million words of pre-2022 Hacker News, all of them replies asking the
169
+ same shape of a person; nothing in public-domain prose outside Joyce's
170
+ dialogue. Several in a draft should be read as a warning.
171
+
172
+ - `is-the-whole-x` (rhetorical-tic, info) is `thats-the-whole` on any
173
+ subject: "That periodicity is the whole tell.", "Consistency is the real
174
+ test." It yields only the exact sentences the two older rules own
175
+ ("that/this is the whole point/game/thing/deal/story" and "is the entire
176
+ point/game/thing/deal/story"), so nothing is reported twice and "This is
177
+ the real test." is not lost. An interrogative subject is out, since a
178
+ question is not a closer; "only", "deal", "thing" and "cost" are left out
179
+ because "is the only thing", "the real deal", "the real thing" and "the
180
+ whole cost" are ordinary speech. Ships at info: about three per million words of
181
+ pre-2022 Hacker News, nothing in public-domain prose. Several in a draft
182
+ should be read as a warning.
183
+
184
+ - `bare-equative` (rhetorical-tic, info) flags a sentence that opens on an
185
+ abstract head noun and equates it with a definite noun phrase: "The tell
186
+ here is the periodicity.", "The lesson is the handoff." The head list is
187
+ the one `the-x-is-the-x` uses, so "The key is the brass thing on the hook"
188
+ is a definition and stays out; the copula (including "isn't") must be
189
+ followed by "the" and a lowercase word, so "The problem is real", "The
190
+ answer is a mess", "the same", "the first" and "the Slack thread" are all
191
+ out; a list marker may open it. Ships at info: about four per million
192
+ words of pre-2022 Hacker News, nothing in public-domain prose. Several in
193
+ a draft should be read as a warning.
194
+
195
+ - `mic-drop-closer` (structure, info) flags the kicker: a sentence of sixty
196
+ or more characters, then a two-to-eight-word closer that ends the
197
+ paragraph and opens on a quantifier or deictic, "Nothing here needs a new
198
+ login.", "Most teams end up with two." A blank line or the end of the
199
+ text must follow the closer, so a bullet followed by another bullet is
200
+ not one; both sentences may be hard-wrapped; and whitespace runs in the
201
+ long sentence are capped, so a URL blanked by `--markdown` cannot make it.
202
+ Ships at info because people end paragraphs this way too, at about 150
203
+ per million words of pre-2022 Hacker News (a comment ends on a verdict);
204
+ one flag means nothing. A draft where it repeats paragraph after
205
+ paragraph is the tell, and the rationale tells the agent to read that as
206
+ a warning.
207
+
208
+ - `short-run` (structure, info) flags three consecutive sentences of thirty
209
+ characters or fewer, each closing on a full stop with no quotation mark:
210
+ "Nobody used it. A named owner. Then a review." The run must start at a
211
+ real sentence boundary, so the short tail of a hard-wrapped sentence never
212
+ opens one; dialogue is out by the boundary, sentences with digits as data,
213
+ initials and abbreviations as not sentence ends, consecutive bullets as a
214
+ list, and questions and exclamations by design. Ships at info: a staccato
215
+ run is a device people use on purpose, at about thirty per million words
216
+ of pre-2022 Hacker News. A draft that keeps doing it is the tell, and the
217
+ rationale tells the agent to read that as a warning.
218
+
219
+ - `epistrophe` (rhetorical-tic, info, off by default) flags two clauses
220
+ that end on the same two-word phrase, the second closing the sentence:
221
+ "built for one desk, and almost no job is done at one desk." Two
222
+ backreferences catch the repeat, so the phrase may be hard-wrapped; an
223
+ article-led phrase, a short second word, a long or punctuated second
224
+ clause, and a clause made of blanked Markdown are all out. It is off by
225
+ default like `rule-of-three`: the figure is one Emerson and Marcus
226
+ Aurelius use on purpose, and on pre-2022 Hacker News most of the 42 hits
227
+ per million words are plain phrase reuse. Select it when a draft is
228
+ suspected of leaning on it; several then should be read as a warning.
229
+
230
+ - `intersection-of` (rhetorical-tic, warning) flags "the intersection of X and
231
+ Y" used as positioning: a writer placed between two fields, saying nothing
232
+ about either. "at" is not required, so "explores the intersection of art and
233
+ technology" flags the same as "sits at the intersection of". Two guards keep
234
+ the literal senses out. A street corner names capitalised streets ("Elm",
235
+ "Broadway", "Highway 12"), so the word after "of" must be lowercase or an
236
+ all-caps acronym; "AI", "UX" and "HCI" are the metaphor's usual operands and
237
+ no street is spelled that way. Geometry and set arithmetic name their operands,
238
+ so a literal noun ("curves", "lines", "arrays", "ranges", "roads") within two
239
+ words of "of" drops the note.
240
+
241
+ - `and-nothing-else` (rhetorical-tic, warning) flags a sentence that closes on
242
+ ", and nothing else", ", nothing more", ", nothing further" or ", and no
243
+ more". A model told to return one thing and nothing else carries the phrasing
244
+ into the prose it writes afterwards, where the exclusion repeats what the
245
+ sentence already said.
246
+ - `nothing-else-frag` (rhetorical-tic, warning) flags the same exclusion built
247
+ as a fragment: "Return the JSON. Nothing else."
248
+ - `honestly`, `honest-x` and `most-honest-x` (rhetorical-tic, warning) answer
249
+ the "honest" habit the way `cleanly`, `clean-x` and `cleanest-x` answer the
250
+ "clean" one. `honestly` takes the manner adverb closing on a full stop or
251
+ comma ("these two rows compare honestly"); `honest-x` takes the adjective in
252
+ front of the writer's own construction ("an honest comparison", "the honest
253
+ framing"); `most-honest-x` takes the self-ranking superlative.
254
+
255
+ `honestly` is anchored on position alone, so a dialogue tag ("said Isabel
256
+ honestly") and the sentence-final hedge of casual speech ("it's beyond boring
257
+ honestly") both match. Both are rare: twice in 3.65M words of public-domain
258
+ prose and ten times in 6.07M words of pre-2022 Hacker News. The two noun
259
+ rules flag nothing in either corpus; "an honest answer" and "an honest
260
+ assessment" are left out because on Hacker News they mean a person telling
261
+ the truth, not a writer praising their own framing.
262
+
263
+ Both rules are wide on purpose. There is no verb list and no imperative
264
+ requirement, so the only narrowing is structural, and English has always used
265
+ this tail: the pair flags three times in 1.92M words of public-domain prose
266
+ and twice in 461k words of pre-2022 Hacker News, and `and-nothing-else` flags
267
+ every refrain in "The Raven". Expect to dismiss it on fiction and on quoted
268
+ verse.
7
269
 
8
270
  ## [0.5.0] - 2026-08-27
9
271
 
data/README.md CHANGED
@@ -10,7 +10,32 @@ A pattern earns a place in the catalog only if it shows up constantly in AI writ
10
10
 
11
11
  ## Installation
12
12
 
13
- Requires **Ruby 3.3+** and nothing else. The runtime is standard library only (`optparse`, `json`, native regex).
13
+ Requires **Ruby 3.3+** and nothing else. The runtime is standard library only (`optparse`, `json`, native regex). The Ruby that macOS ships at `/usr/bin/ruby` is 2.6 and too old; `brew install ruby` gets you a current one.
14
+
15
+ ### In Claude Cowork or the Claude desktop app
16
+
17
+ Open **Customize → Plugins → Add marketplace** and paste exactly this:
18
+
19
+ ```
20
+ benjaminjackson/sloplint
21
+ ```
22
+
23
+ Not the web address of the page you are reading — just those two words with the slash, and no slash on the end. `https://github.com/benjaminjackson/sloplint` works too, but a link copied from a page you were browsing usually has more on the end of it and will fail. Then install **sloplint** from the list.
24
+
25
+ Installing this way needs no package install of any kind, which is why it works in Cowork, where `gem install` cannot reach the internet. Claude Code carries the source along with the plugin and runs it in place.
26
+
27
+ New rules reach you when you click **Update** on the marketplace. Nothing updates on its own.
28
+
29
+ Once it is installed, ask Claude to check a draft, or run `/sloplint:check`.
30
+
31
+ ### In Claude Code
32
+
33
+ ```
34
+ /plugin marketplace add benjaminjackson/sloplint
35
+ /plugin install sloplint@sloplint
36
+ ```
37
+
38
+ ### In a terminal
14
39
 
15
40
  ```bash
16
41
  gem install sloplint
@@ -125,22 +150,24 @@ Three codes carry the contract. A crash exits nonzero on its own.
125
150
  | 1 | ran, notes found |
126
151
  | 2 | bad arguments or usage error |
127
152
 
128
- An unknown id or category in `--select`/`--ignore` is a usage error (exit 2, naming the id) rather than a silent no-op, so a typo can't masquerade as a clean scan.
153
+ An unknown id or category in `--select`/`--ignore` is a usage error (exit 2, naming the id) rather than a silent no-op, so a typo can't masquerade as a clean scan. Input that is empty or only whitespace is exit 2 for the same reason: a pipe that delivered nothing must not read as a clean draft. Only when every source is empty — one empty file among several named ones is taken as deliberate.
129
154
 
130
155
  ## The rule catalog
131
156
 
132
- 50 rules across four categories. `sloplint rules` prints them; `sloplint rules --json` gives an agent the enumerable form.
157
+ 75 rules across four categories. `sloplint rules` prints them; `sloplint rules --json` gives an agent the enumerable form.
133
158
 
134
- - **rhetorical-tic** (36) 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`, `hold-onto-that`, `cleanly`, `clean-count`, `cleanest-x`, `clean-x`, `not-nothing`, `is-is` (doubled copula), `if-im-being-honest` (the candor preamble, from slopwash.com's "false intimacy"), and more.
135
- - **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`.
136
- - **structure** (8) `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), `question-isnt` (the corrective frame in interrogative dress), `less-about-more-about`, `trailing-significance-participle` (the "…, showcasing its importance" clause), and `rule-of-three`.
159
+ - **rhetorical-tic** (53) the cadence patterns: `no-x-no-y`, `no-x-no-y-frag`, `from-x-to-y-chain` ("from guessing to measuring, from hoping to knowing"), `one-x-one-y` ("one reviewer, one queue, one deadline"), `same-determiner-chain` (any other repeated determiner, at `info`), `and-what-it-should` (the elliptical tail: "…, and what it should."), `abstract-lives-in` ("the value sits in the follow-up", at `info`), `the-x-is-the-x` ("the problem with A is the problem with B"), `bare-equative` ("The lesson is the handoff.", at `info`), `thats-the-whole`, `is-the-whole-x` (the same closer on any subject: "Consistency is the real test.", at `info`), `thats-how-x`, `announced-takeaway`, `exact-exactly`, `load-bearing`, `intersection-of`, `you-already-know`, `sit-with-that`, `hold-onto-that`, `cleanly`, `clean-count`, `cleanest-x`, `clean-x`, `not-nothing`, `is-is` (doubled copula), `if-im-being-honest` (the candor preamble, from slopwash.com's "false intimacy"), `and-nothing-else` (the trailing "…, and nothing else"), `honestly`, `honest-x`, `most-honest-x` (the honesty family, built the same way as the four `clean` rules above), `impact-verb` ("the outage impacted four thousand accounts"), `impact-noun-bare` ("the impact of X", at `info` because research prose uses it straight), and more.
160
+ - **puffery** (6) five from Wikipedia's "signs of AI writing" `puffery-words` (vibrant, nestled, groundbreaking, in the heart of), `rich-tapestry`, `vital-role`, `stands-serves-as`, `underscores-highlights` — plus `impact-noun-vague` ("a significant impact", "make an impact").
161
+ - **structure** (15) `everyone-nobody` (the comma-spliced antithesis: "Everyone wants the dashboard, nobody maintains it."), `np-fragment-and` (the verbless "A named owner and a quarterly review.", at `info`), `quip-question` (the verbless "No invite?", at `info`), `mic-drop-closer` (the short quantifier-led sentence that ends a paragraph after a long one, at `info`; a draft full of them is a warning), `short-run` (three sentences of thirty characters or fewer in a row, at `info`, same caveat), `not-just-x-but-y`, `not-x-but-y` (the bare corrective), `isnt-x-its-y` (the same corrective split across two clauses: "It isn't the tool. It's the habit."), `not-by-x-but-by-y` (the corrective on a repeated preposition: "not by luck, but by design"), `em-dash` (any em dash), `em-dash-overuse` (three or more in one paragraph), `question-isnt` (the corrective frame in interrogative dress), `less-about-more-about`, `trailing-significance-participle` (the "…, showcasing its importance" clause), and `rule-of-three`.
137
162
  - **hedging** (1) `vague-attribution`: "some critics argue," "it is widely regarded."
138
163
 
139
164
  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.
140
165
 
141
166
  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`, and with `notice-what-there` and `notice-what`. 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.
142
167
 
143
- Two rules ship **off by default**, and both run only when you name them: `sloplint check --select rule-of-three -`. `rule-of-three` flags three parallel comma items closing a sentence, which humans do all the time. `genuinely` flags every occurrence of the word; as an intensifier it rates the writer's sincerity, but it still does real work when it draws a contrast, and nothing in the sentence separates the two.
168
+ `and-nothing-else` and `nothing-else-frag` are a pair of the same shape, but both ship at `warning`. That is a deliberate exception: the fragment half carries a capital letter and a whole-sentence requirement that the comma half has no equivalent of, so it is the *narrower* of the two rather than the quieter one.
169
+
170
+ Three rules ship **off by default**, and they run only when you name them: `sloplint check --select rule-of-three -`. `rule-of-three` flags three parallel comma items closing a sentence, which humans do all the time. `genuinely` flags every occurrence of the word; as an intensifier it rates the writer's sincerity, but it still does real work when it draws a contrast, and nothing in the sentence separates the two. `epistrophe` flags two clauses ending on the same phrase, a named figure that careful writers use on purpose and that, on Hacker News, is mostly plain phrase reuse.
144
171
 
145
172
  ### Markdown handling
146
173
 
data/docs/SPEC.md CHANGED
@@ -93,13 +93,15 @@ dependencies at runtime.
93
93
  Rationale: rules are regexes; the whole thing is a scanner plus an output
94
94
  formatter. A dependency-free `gem install sloplint` is the robust, boring
95
95
  choice. The name is free on RubyGems (taken on PyPI, so this also sidesteps the
96
- collision). Ships a `sloplint` executable via the gemspec's `bin`.
96
+ collision). Ships a `sloplint` executable from `exe/` (claude.ai rejects a
97
+ plugin with a top-level `bin/`, and sloplint ships as a Claude Code plugin
98
+ too).
97
99
 
98
100
  Package layout (standard gem):
99
101
 
100
102
  ```
101
103
  sloplint/
102
- bin/sloplint # thin shim: require "sloplint/cli"; exit Sloplint::CLI.run(ARGV)
104
+ exe/sloplint # thin shim: require "sloplint/cli"; exit Sloplint::CLI.run(ARGV)
103
105
  lib/sloplint.rb # requires the pieces below
104
106
  lib/sloplint/version.rb
105
107
  lib/sloplint/cli.rb # optparse, subcommands, exit codes
@@ -161,6 +163,11 @@ Three codes carry the contract. A crash just exits nonzero on its own.
161
163
  | 1 | ran, **notes found** |
162
164
  | 2 | bad arguments / usage error |
163
165
 
166
+ Empty or whitespace-only input is exit 2, like a mistyped rule id: a scan of
167
+ nothing must not report as a clean scan. The text is tested before
168
+ `--markdown` blanks code and URLs, so a file that holds only a fenced code
169
+ block still exits 0. Only when every source is empty.
170
+
164
171
  ## Note (the diagnostic object)
165
172
 
166
173
  One match = one Note. JSON output is an array of these (or an object keyed by
@@ -270,12 +277,20 @@ false-positive risk, which none has demonstrated.
270
277
  |----|---------|-------|
271
278
  | `no-x-no-y` | 2+ comma-separated "no …" items in a row | counts items |
272
279
  | `no-x-no-y-frag` | the same cadence as sentence fragments ("No fluff. No filler.") | counts items; `info` |
273
- | `thats-the-whole` | "that/this is the whole point/game/thing…" | |
280
+ | `thats-the-whole` | "that/this is the whole point/game/thing…" | also value and fix, the closers agents write in technical prose, when they end the sentence or the paragraph or run into a closer's tail word; either apostrophe; `is-the-whole-x` yields the same list |
281
+ | `is-the-whole-x` | any subject + "is the whole/real/actual/entire N" (tell, point, test, work, …) | opens on the subject word; yields only the exact sentences `thats-the-whole` and `is-the-entire` own; interrogative subjects out; "only", "deal", "thing", "cost" left out; `info` |
274
282
  | `did-not-x-did-not-y` | 2+ "did not …"/"didn't …" in a row | counts items |
283
+ | `from-x-to-y-chain` | 2+ comma-separated "from X to Y" spans | counts spans; operands open with a letter, so ranges are out; skips the relay ("to B, from B") and the reduplication ("from X to X") |
284
+ | `one-x-one-y` | 3+ comma-separated "one X" items standing on their own | counts items; must open a sentence or follow a colon, so a chain after a verb is counting; items letter-led; the distributive "one for …" skipped; a pair never flags |
285
+ | `and-what-it-should` | ", and what it should." — a second "what" clause closing on a bare modal or a negated auxiliary | comma, conjunction and full stop required; the affirmative copula and do-verb ("what he does.") are complete clauses and out; a question is out |
286
+ | `abstract-lives-in` | an abstract noun that "lives/sits in/between/inside/…" | closed subject list; capitalised subjects skipped; "with" (responsibility), "at" (quantity), "lies in" and "resides in" left out; `info` |
287
+ | `the-x-is-the-x` | "the X … is the X …": the same abstract head noun on both sides of the copula | backreference; closed list of heads that cannot name an object (key, cost, unit are out); the clause between is capped at 50 chars and may not hold a comma, semicolon or colon; the second head must be followed by a preposition, determiner, quantifier, pronoun, plural noun or punctuation, so compounds are out; "isn't" counts |
288
+ | `same-determiner-chain` | 3+ comma-separated items opening on the same determiner or quantifier (every, each, your, more, …) | backreference; counts items; "one" and "no" left to their own rules; the narrative possessives (my, his, her, their, its) left out; items lowercase-led, so proper nouns are not a chain; `info` |
289
+ | `bare-equative` | sentence-initial "The N (here) is (not) the …" with an abstract head noun | the-x-is-the-x's head list, so concrete heads are out; the copula (is, is not, isn't) must be followed by "the" and a lowercase word, so predicate adjectives, indefinites, pointing complements ("the same/one/first/…") and proper nouns are out; a list marker may open it; `info` |
275
290
  | `dont-verb-it` | "Don't call it X. Call it Y." (negated verb+it, same verb+it) | |
276
291
  | `sit-with-that` | "sit with that/this/it", "sit with the discomfort" | |
277
292
  | `hold-onto-that` | sentence-initial "hold onto/on to that/this" | imperative only |
278
- | `cleanly` | "cleanly" anywhere | no verb list; the engineering idiom counts too |
293
+ | `cleanly` | "cleanly" into/onto/in two/in half — the partition frame | the preposition is the narrowing; the engineering idiom ("applies cleanly", "separated cleanly", "cleanly compiled") is a checkable fact and stays out, and the clause-final form is left out. `info`: the frame is not the sense, and separating "splits cleanly into two parts" from "retracted cleanly into the well" needs the subject |
279
294
  | `clean-count` | "two/three clean parts/buckets/categories…" | needs a partition noun |
280
295
  | `cleanest-x` | "the cleanest framing/formulation", "cleanest way to put it" | noun list only |
281
296
  | `clean-x` | "a clean abstraction/distinction/framing", "clean line between" | `info` |
@@ -287,8 +302,11 @@ false-positive risk, which none has demonstrated.
287
302
  | `worth-naming` | "worth naming/flagging/separating/spelling out" | skip "naming names"; yields to the rule below when a manner adverb follows; `info` |
288
303
  | `worth-saying-plainly` | "it's worth saying plainly / better put bluntly…", plus the bare "Put plainly," / "Said bluntly," | sentence-initial; the bare branch drops "simply"/"clearly" so "put simply" and "simply put" stay clean |
289
304
  | `not-nothing` | copula + "not nothing" litotes, any subject | skip personal/there subjects |
290
- | `exact-exactly` | "exact"/"exactly" | allowlist for the checkable uses; `info` |
305
+ | `exact-exactly` | the reflexive intensifier: "that's exactly", "exactly right", "exactly the point", "the exact problem", "know exactly why" | matches the tell as a closed set of frames rather than matching the word and subtracting an allow-list; a measured quantity ("the exact diameter", "the exact CPU time") is silent by construction. `info` |
291
306
  | `load-bearing` | "load-bearing" outside its construction sense | skip building nouns either side |
307
+ | `intersection-of` | "the intersection of X and Y" as positioning | skip street corners, geometry, set arithmetic, airfield surfaces (runway, taxiway, apron), matrix rows and columns, and operands shaped like a US route designator ("US-27A") or a quadrant plus house number ("NE 140th Court") |
308
+ | `impact-verb` | "impact" used as a verb ("the outage impacted 4,000 accounts") | needs an auxiliary or subject pronoun for the base form; "to impact" requires a following object, so the preposition ("prior to impact") stays out; skip the medical and soil sense of "impacted", the struck object of a real collision ("impacted terrain"), the fixed compounds, and hyphenated forms |
309
+ | `impact-noun-bare` | "the impact of X", "measure the impact" — `info` | needs a measuring verb in front or "of" behind; skip the collision sense and the fixed compounds |
292
310
  | `thats-how-x` | sentence-initial "that's how…" | |
293
311
  | `announced-takeaway` | colon-led label: "The pattern/lesson/takeaway…:" | sentence-initial |
294
312
  | `earns-its-place` | "earns its place/keep" (any possessive) | possessive required; `warning` |
@@ -301,8 +319,14 @@ false-positive risk, which none has demonstrated.
301
319
  | `notice-what` | bare sentence-initial "Notice what…" | yields the "there" frame to the rule above; "how" excluded; `info` |
302
320
  | `none-of-this-is-to-say` | "none of this/that/the above is to say" | every other "not to say" phrasing excluded |
303
321
  | `if-im-being-honest` | "if I'm/we're (being) honest", "honestly, the answer/truth" | plain "to be honest" and "I'll be honest" excluded |
322
+ | `honestly` | a word, then "honestly", then a full stop or comma | terminal position required; discourse-marker slot guarded ("and/quite/but honestly,"); no animacy test |
323
+ | `honest-x` | "an honest comparison", "the honest framing" | short noun list; "answer", "assessment", "account" excluded as ordinary; superlative yielded to the rule below |
324
+ | `most-honest-x` | "the most honest framing", "the most honest way to put it" | wider noun list than `honest-x`; no human nouns, so "the most honest person" stays out |
304
325
  | `genuinely` | any "genuinely" | off by default; no narrowing holds |
326
+ | `epistrophe` | two clauses ending on the same two-word phrase, the second closing the sentence | two backreferences, so the phrase may be hard-wrapped; no article-led phrase, second word 4+ letters, second clause 5–60 chars with no internal punctuation and capped whitespace; off by default; `info` |
305
327
  | `and-thats-fine` | "And that's fine/okay." as a whole sentence | "and" required; must open and close the sentence |
328
+ | `and-nothing-else` | trailing ", and nothing else/more/further", ", and no more" | tail must close the sentence; comma required; bare "no more" needs "and"; "?" excluded |
329
+ | `nothing-else-frag` | the same exclusion as a fragment: "Nothing else." | sentence-initial capital; semicolon excluded; must be the whole sentence; "No more." left out |
306
330
  | `is-is` | doubled copula: "what it is is …", "the thing is, is that …" | comma optional |
307
331
 
308
332
  ### puffery (Wikipedia: Signs of AI writing)
@@ -314,11 +338,19 @@ Single flat rule per word-cluster, matched as whole words:
314
338
  - `vital-role` — "plays a (vital/crucial/pivotal/significant/key) role".
315
339
  - `underscores-highlights` — "underscore(s)" + determiner and "underscored/underscoring" anywhere (the emphasis verb); "highlights/emphasizes its (importance/significance)" stays narrow.
316
340
  - `rich-tapestry` — "rich tapestry", "tapestry of".
341
+ - `impact-noun-vague` — an intensity adjective plus "impact" (significant, real, meaningful, lasting, massive, huge, profound, big and the rest), or "make/have an impact". "positive" and "negative" stay out: they name a direction, which is more than the intensity words do. "statistically significant impact" is skipped — that one is a finding.
317
342
 
318
343
  ### structure
319
344
 
320
345
  - `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.
321
346
  - `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.
347
+ - `isnt-x-its-y` — the corrective with no conjunction: a negated copula, then a second clause that supplies the replacement ("It isn't the tool. It's the habit."), joined by a period, semicolon, comma, or dash; `info`. Neither complement may open with a pronoun, possessive, preposition ("about" excepted), degree word, or predicate adjective, which keeps out ordinary two-part contrast; some hits are still the same shape written by a person.
348
+ - `not-by-x-but-by-y` — the corrective anchored on a repeated preposition rather than a copula: "not by A, but by B", "not from A but from B" (by, for, from, with, about, in, on, at, to, of, through, because of, out of); `info`. The B side must repeat the A preposition, A is capped at six words, and B may not open with a pronoun. Every corpus hit is the real shape written by a person, which is why it sits at info rather than being narrowed further.
349
+ - `everyone-nobody` — the comma-spliced antithesis on quantifier subjects: one clause opens on everyone/everybody, the other on nobody/no one/none or "one N", joined by a bare comma, the second closing the sentence ("Everyone wants the dashboard, nobody maintains it."). A conjunction, a period, or the same subject twice is not a hinge; "one of/by/per", a measure, a proper noun, "none of which" and "none louder than" are not second subjects; clauses are capped at eighty characters.
350
+ - `np-fragment-and` — a whole sentence made of two noun phrases and an "and", opening on A/An/One at a sentence start or after a list marker ("A named owner and a quarterly review."). One to three words a side, no auxiliary or modal anywhere (contractions included); a lexical verb is invisible, so "A car and a truck collided." flags, which is why it ships at `info`.
351
+ - `quip-question` — the verbless opening question ("No invite?", "New to the tool?", "Still stuck?"): sentence-initial, one of a short list of opening words, one to four more words, no auxiliary or contraction, closing on the question mark; "Need" and "Want" are left out as elided verbs; `info`, because people ask the same shape in conversation.
352
+ - `mic-drop-closer` — a sentence of 60+ characters, then a paragraph-final closer of two to eight words opening on a **quantifier** (Nothing, Most, None, Everything, Everyone, Nobody, Then, Neither, Both): "Nothing here needs a new login."; `info`. The bare demonstratives (That, This, It) were in the list and are out: procedural writing ends a step with one as a matter of course ("This completes the roughing operations."). A blank line or the end of the text must follow the closer; both sentences may be hard-wrapped; whitespace runs in the long sentence are capped so blanked Markdown cannot make it, and the long-sentence prefix is an atomic group so an unpunctuated stretch cannot send it into catastrophic backtracking. The note points at the closer. One means nothing; a draft where it repeats is the tell.
353
+ - `short-run` — three consecutive sentences of thirty characters or fewer, each letter-led and closing on a full stop, no quotation marks or digits, no lone-letter labels (either case, though a possessive is not one) or abbreviations, starting at a real sentence boundary (never on a wrap continuation), crossing a hard wrap but not a paragraph break; `info`. A list marker may open a run but never sit inside one, so consecutive bullets are a list — the marker set covers glyphs, the literal "o" plain-text documents use as a bullet, and numbered and lettered items. Two whole-run exclusions keep document furniture out, each a property of the run rather than of any sentence in it: three single-word sentences in a row is a citation line ("Natl. Inst. Stand. Technol."), and three lowercase-led sentences in a row is transcribed speech ("we all get gas. we go to divert to Albany."). One single-word sentence is the archetypal kicker and stays, and a run that reaches a capital anywhere is prose, so identifier-initial writing ("npm was slow. git blame helped. We moved on.") is untouched. One is a question; a draft that repeats it is the tell.
322
354
  - `question-isnt` — "the question isn't/is not (whether|if|how|what|why|who) X, it's/but Y"; `info`. The resolving clause is required, so a plain rhetorical question never matches; "the real question is" is excluded.
323
355
  - `less-about-more-about` — "it's/this is/that's less about X (and) more about Y", also "… than about Y"; `info`. Both halves of the frame are required, and the subject slot is limited to the pronouns.
324
356
  - `trailing-significance-participle` — comma plus a participle from a closed verb list (highlighting, showcasing, reinforcing, shaping, enhancing, cementing, solidifying, embodying, fostering, facilitating, signalling), the clause a model hangs off a sentence to say what a fact means. Guards drop gerund lists and "signalling to". `driving`, `representing`, `reflecting`, `marking`, `contributing`, `illustrating`, `demonstrating`, `emphasising`, `echoing` and `affirming` stay out: humans write them in the same position, usually with a person as the subject, and the pattern cannot see the subject. `underscoring` is left to `underscores-highlights`.
data/exe/sloplint ADDED
@@ -0,0 +1,13 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ # Integer compare, not string: "3.10" < "3.3" is true and would reject a future
5
+ # Ruby. Array has <=> but no <, hence the explicit compare. Not Gem::Version --
6
+ # this has to hold when RubyGems is not loaded.
7
+ if (RUBY_VERSION.split(".").map(&:to_i).first(2) <=> [3, 3]) < 0
8
+ abort "sloplint needs Ruby 3.3 or later, found #{RUBY_VERSION}.\n" \
9
+ "macOS ships Ruby 2.6 at /usr/bin/ruby. Install a current one with `brew install ruby`."
10
+ end
11
+
12
+ require_relative "../lib/sloplint/cli"
13
+ exit Sloplint::CLI.run(ARGV)
data/lib/sloplint/cli.rb CHANGED
@@ -10,7 +10,8 @@ require_relative "output"
10
10
  module Sloplint
11
11
  # Command-line shell: optparse, subcommands, exit codes. See docs/SPEC.md.
12
12
  #
13
- # Exit codes: 0 ran/no notes, 1 ran/notes found, 2 bad arguments.
13
+ # Exit codes: 0 ran/no notes, 1 ran/notes found, 2 bad arguments. Empty
14
+ # input is a 2 as well: an unread draft must not report as a clean one.
14
15
  module CLI
15
16
  module_function
16
17
 
@@ -68,20 +69,38 @@ module Sloplint
68
69
  paths = argv.empty? ? ["-"] : argv
69
70
  by_path = paths.reject { |x| x == "-" }.size > 1
70
71
 
71
- all_notes = []
72
+ sources = []
72
73
  paths.each do |path|
74
+ # Read as UTF-8 whatever the locale says. A sandbox with no LANG set
75
+ # leaves Ruby's default external encoding at US-ASCII, and then the
76
+ # first em dash raises "invalid byte sequence in US-ASCII" -- on prose
77
+ # that is perfectly valid UTF-8. Prose is the only input sloplint
78
+ # takes, so UTF-8 is the assumption, not the locale's guess.
73
79
  text =
74
80
  if path == "-"
75
- stdin.read
81
+ stdin.read.force_encoding(Encoding::UTF_8)
76
82
  else
77
83
  unless File.file?(path)
78
84
  err.puts("sloplint: no such file: #{path}")
79
85
  return 2
80
86
  end
81
- File.read(path)
87
+ File.read(path, encoding: Encoding::UTF_8)
82
88
  end
83
- label = path == "-" ? "-" : path
84
- all_notes.concat(Engine.scan(text, rules:, markdown:, path: label))
89
+ sources << [path == "-" ? "-" : path, text]
90
+ end
91
+
92
+ # Tested on the raw text, before --markdown blanks code and URLs: a file
93
+ # that holds only a fenced code block did arrive, and scanning it clean
94
+ # is right. Nothing arriving at all is the trap -- the same one a
95
+ # mistyped rule id sets, and it exits 2 for the same reason.
96
+ if sources.all? { |_, text| text.strip.empty? }
97
+ names = sources.map { |label, _| label == "-" ? "stdin" : label }
98
+ err.puts("sloplint: empty input: nothing to check in #{names.join(", ")}")
99
+ return 2
100
+ end
101
+
102
+ all_notes = sources.flat_map do |label, text|
103
+ Engine.scan(text, rules:, markdown:, path: label)
85
104
  end
86
105
 
87
106
  case opts[:format]
@@ -93,7 +112,10 @@ module Sloplint
93
112
  end
94
113
 
95
114
  all_notes.empty? ? 0 : 1
96
- rescue ArgumentError => e
115
+ # Invalid UTF-8 reaches this two ways: String#strip in the empty check
116
+ # raises Encoding::CompatibilityError, the engine's regexes raise
117
+ # ArgumentError. Both are the same thing to the reader.
118
+ rescue ArgumentError, Encoding::CompatibilityError => e
97
119
  err.puts("sloplint: invalid input: #{e.message}")
98
120
  2
99
121
  end
@@ -187,7 +209,7 @@ module Sloplint
187
209
 
188
210
  # Recommended for agents:
189
211
  cat FILE | sloplint check --markdown -o json -
190
- # exit 0 = clean, 1 = notes found, >1 = error
212
+ # exit 0 = clean, 1 = notes found, >1 = error (empty input is an error)
191
213
  # each note: {path,line,column,severity,rule,category,message,excerpt,context,rationale,suggestion}
192
214
 
193
215
  usage: sloplint [-o full|json] [command] [args]