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