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 +4 -4
- data/CHANGELOG.md +46 -1
- data/README.md +35 -23
- data/docs/SPEC.md +55 -9
- data/lib/sloplint/cli.rb +11 -3
- data/lib/sloplint/engine.rb +32 -2
- data/lib/sloplint/output.rb +3 -2
- data/lib/sloplint/rules.rb +194 -15
- data/lib/sloplint/version.rb +1 -1
- metadata +2 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 846ce6e663bb01d1dd3e054f328eaccb423745aeb34def94b8ce33253d960c23
|
|
4
|
+
data.tar.gz: f165880bb800c66a57d88ffd7e9876c4426a7607a6f4b68582d8f0365c728956
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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.
|
|
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
|
-
$
|
|
44
|
-
-:1:
|
|
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:
|
|
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
|
-
-:
|
|
53
|
-
excerpt: That
|
|
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:
|
|
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
|
-
|
|
131
|
+
27 rules across four categories. `sloplint rules` prints them; `sloplint rules --json` gives an agent the enumerable form.
|
|
125
132
|
|
|
126
|
-
- **rhetorical-tic** (
|
|
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** (
|
|
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: "
|
|
146
|
-
category: "rhetorical-tic",
|
|
147
|
-
severity: "warning",
|
|
148
|
-
pattern:
|
|
149
|
-
message:
|
|
150
|
-
suggestion: "
|
|
151
|
-
count_group:
|
|
152
|
-
skip: [
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
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
|
|
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
|
|
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
|
|
146
|
-
Does not: #{rule.examples_ok
|
|
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
|
|
data/lib/sloplint/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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 --
|
data/lib/sloplint/output.rb
CHANGED
|
@@ -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.
|
|
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
|
|
data/lib/sloplint/rules.rb
CHANGED
|
@@ -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: "
|
|
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: "
|
|
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: "
|
|
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'
|
|
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
|
-
|
|
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
|
|
296
|
-
|
|
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
|
|
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: "
|
|
357
|
-
"
|
|
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 ───────────────────────────────────────────────────────────
|
data/lib/sloplint/version.rb
CHANGED
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.
|
|
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:
|
|
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: []
|