sloplint 0.9.0 → 0.10.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 +126 -0
- data/README.md +162 -129
- data/docs/SPEC.md +6 -2
- data/lib/sloplint/cli.rb +42 -10
- data/lib/sloplint/engine.rb +8 -0
- data/lib/sloplint/output.rb +1 -1
- data/lib/sloplint/rules.rb +215 -4
- data/lib/sloplint/split.rb +40 -13
- data/lib/sloplint/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 104eaad3b0c8de01284f36733d607140b606feb03b9f216225e62f2a25ade435
|
|
4
|
+
data.tar.gz: b6d8cf5cf2eb4bcaa01b99e2889f4aaa5f0fbbbce6c5663cdaf79bff45dcbc76
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 590f441d103f51d263e8eb073c48f363ffceae3619b52b68360c7bf973daf38cbf3e31c18a0816d61d2f1bddc03207bafa19b4f709535ce1dc67eb7a0694aaa1
|
|
7
|
+
data.tar.gz: 247c89c135fa14d90b2e37f67753451cc0c9d945f149bee17186ee8ebdd5faacc87db25e72d682a9a4500659e75c9b79810bec44478816776cefaf0f7a22e17a
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,86 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
5
5
|
|
|
6
6
|
## sloplint-judge
|
|
7
7
|
|
|
8
|
+
### [0.2.0] - 2026-10-13
|
|
9
|
+
|
|
10
|
+
- New sentence rule `positive-form` (`info`, `low`, off by default): a claim
|
|
11
|
+
made by denial instead of a plain positive statement (Strunk and White,
|
|
12
|
+
rule 15) --
|
|
13
|
+
a correction with "not", "nothing" or "nobody" as subject or object, or an
|
|
14
|
+
instruction not to do something. A negative outside the claim passes: a
|
|
15
|
+
condition, a bound, a fixed name, a standard's MUST NOT, quoted speech, or
|
|
16
|
+
a denied option a decision names on its way to a different one. A verb
|
|
17
|
+
that performs an absence ("publishes no salary band") still flags; an
|
|
18
|
+
absence stated as a property of the thing ("has no salary band", "without
|
|
19
|
+
a salary band") passes, and so does an expected behavior denied on its own
|
|
20
|
+
("does not retry") with nothing set against it. A routing instruction
|
|
21
|
+
(which tool to use for which job) and a design choice given with its
|
|
22
|
+
reason also pass. Off by default because reference documentation states
|
|
23
|
+
limits as negatives on purpose.
|
|
24
|
+
- New sentence rule `name-the-thing` (`info`, `low`, off by default): referring to a
|
|
25
|
+
thing by anything other than its name when a name is available --
|
|
26
|
+
a position (the latter, the second one), a stand-in noun or figure (the
|
|
27
|
+
engine for a named service), an allusion to a divide it never names, or
|
|
28
|
+
the document pointing at itself ("this entire writeup"). A position that
|
|
29
|
+
is the thing's own name (step 4, the second retry, Q3), a which-clause
|
|
30
|
+
about named things, and a pronoun pointing at something just named pass.
|
|
31
|
+
- New sentence rule `device-before-claim` (`info`, `medium`): a sentence
|
|
32
|
+
that makes its claim through a device -- a mirror or matched pair, a
|
|
33
|
+
riddle, a held-back answer, a singled-out position in a list, a coined
|
|
34
|
+
figure, or a maxim -- instead of stating it plainly. Detection only; the
|
|
35
|
+
calling model decides whether the device earns its keep.
|
|
36
|
+
- New sentence rule `maxim` (`info`, `medium`): a sentence that states a
|
|
37
|
+
general saying in place of the fact the case needs, often as a
|
|
38
|
+
paragraph's closing line.
|
|
39
|
+
- New sentence rule `dressed-pair` (`info`, `low`, off by default): one
|
|
40
|
+
fact said as if it were two -- a second clause that echoes the first
|
|
41
|
+
clause's verb, or a "both" / "those two things" summing up an earlier
|
|
42
|
+
pair and handed a verdict. It catches pairs that `matched-shape` and
|
|
43
|
+
`name-the-thing` miss.
|
|
44
|
+
- New sentence rule `mirrored-opposites` (`info`, `low`, off by default):
|
|
45
|
+
opposite words joining two separate claims so they read as a contrast
|
|
46
|
+
("identical everywhere it runs and documented nowhere"). Opposites that
|
|
47
|
+
compare one thing across two cases ("runs in staging and never in
|
|
48
|
+
production") pass. The narrow case of `matched-shape`.
|
|
49
|
+
- `no-news` also flags a sentence that tells the reader, in the second
|
|
50
|
+
person, about their own past actions or their own past words. A fact is
|
|
51
|
+
not news to the reader who lived it, even when the sentence is specific.
|
|
52
|
+
- `names-nothing` judges each sentence with its paragraph. A sentence
|
|
53
|
+
passes when its paragraph makes the claim checkable, and so does a short
|
|
54
|
+
label or verbless fragment that the next sentence spells out. Pointing
|
|
55
|
+
back at things the paragraph named ("these", "this design") does not by
|
|
56
|
+
itself make a new general claim checkable.
|
|
57
|
+
- `stock-figure` passes names the writer chose: a command or identifier in
|
|
58
|
+
code, one of a set of options, statuses or modes the paragraph lists, and
|
|
59
|
+
a term the paragraph defines, the defining sentence included. Bold counts
|
|
60
|
+
only when the paragraph sets the phrase up as a name. A stock figure
|
|
61
|
+
elsewhere in the same sentence still flags.
|
|
62
|
+
- `unnamed-authority` also flags a claim called known with no source ("a
|
|
63
|
+
known cause", "it is well known that"). A known fact passes when the
|
|
64
|
+
sentence or its paragraph cites a source for it. In an abstract, a result
|
|
65
|
+
the field is said to know is prior work and passes; a person described as
|
|
66
|
+
well known is a reputation, not a claim.
|
|
67
|
+
- Graveyard: `stand-in-verb`, a rule for a verb of holding, carrying or
|
|
68
|
+
keeping standing in for a plain verb ("holds the history of a job"), was
|
|
69
|
+
added and reverted. Its only real specimens were two labelled rows, and
|
|
70
|
+
the maintainer ruled that their verbs are worn figures in their own
|
|
71
|
+
trades, not this tell. A version widened to any figurative main verb
|
|
72
|
+
read as ornate prose (`stock-figure`'s job) rather than a stand-in for
|
|
73
|
+
who knows, wrote or decides.
|
|
74
|
+
- The default `--register` is now "the reader this document is written
|
|
75
|
+
for", in place of "an engineer on the team reading a design document".
|
|
76
|
+
The model infers the reader from the text, so a run without `--register`
|
|
77
|
+
can flag different sentences than before. Name the reader when you know
|
|
78
|
+
it.
|
|
79
|
+
- `script/calibrate run --reader TEXT` asks every register's questions for
|
|
80
|
+
one reader, to measure a candidate default against each register's own
|
|
81
|
+
reader.
|
|
82
|
+
- `script/calibrate` retries API errors with backoff, including the
|
|
83
|
+
compare calls, and gains a `blogs` register (HAP-E) alongside RAID's news
|
|
84
|
+
and abstracts. A failed HAP-E download now stops with an error. `script/score-labels`
|
|
85
|
+
is a new script that scores a rule against a synthetic or real label set
|
|
86
|
+
by name.
|
|
87
|
+
|
|
8
88
|
### [0.1.0] - 2026-09-21
|
|
9
89
|
|
|
10
90
|
- New sentence rule `unnamed-authority` (`info`, `medium`): a claim handed to
|
|
@@ -81,6 +161,52 @@ follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
|
|
|
81
161
|
|
|
82
162
|
## sloplint
|
|
83
163
|
|
|
164
|
+
## [0.10.0] - 2026-10-13
|
|
165
|
+
|
|
166
|
+
### Added
|
|
167
|
+
|
|
168
|
+
- New rule `the-x-is-not-the-x` (cadence, `warning`, `medium`): one head noun on both sides of
|
|
169
|
+
a negated copula, split in two by a clause and never told apart ("the
|
|
170
|
+
one you want is not the one that runs the studio"). The negated twin of
|
|
171
|
+
`the-x-is-the-x`.
|
|
172
|
+
- New rule `negative-subject` (cadence, `warning`, `medium`): a negative pronoun (Nothing,
|
|
173
|
+
Nobody, No one, None) standing as the subject of a clause, dressed up
|
|
174
|
+
with a second negative-subject clause joined by "and", or a negated
|
|
175
|
+
relative clause on the object ("nothing gets merged that
|
|
176
|
+
does not get reviewed."). Bare "Nothing happened." and "No one knows."
|
|
177
|
+
are left alone.
|
|
178
|
+
|
|
179
|
+
### Changed
|
|
180
|
+
|
|
181
|
+
- Full output (`-o full`) shows each note's confidence next to its
|
|
182
|
+
severity. The `--strict` help, in `sloplint check` and `sloplint-judge check`,
|
|
183
|
+
says it also reports low-confidence notes, and that those are places to
|
|
184
|
+
look, not findings.
|
|
185
|
+
- `--register` under `--judge` now defaults to "the reader this document
|
|
186
|
+
is written for" instead of a design-document reader, and its help asks
|
|
187
|
+
you to name the real reader. The check skill passes one.
|
|
188
|
+
- `sloplint explain ID` also explains the judge's rules when sloplint-judge
|
|
189
|
+
is installed, so every rule id in a `check --judge` note explains from
|
|
190
|
+
one command. `--help` and the check skill say so.
|
|
191
|
+
- Under `--strict`, a run that reports low-confidence notes says on stderr
|
|
192
|
+
how many there are and that they are places to look, not findings.
|
|
193
|
+
stdout is unchanged, so JSON output still parses. The check skill no
|
|
194
|
+
longer adds `--strict` unless asked, and puts a low-confidence note to
|
|
195
|
+
the person as a question instead of editing on it.
|
|
196
|
+
|
|
197
|
+
### Fixed
|
|
198
|
+
|
|
199
|
+
- `rule-of-three` no longer flags the last three words of a longer list,
|
|
200
|
+
including a list hard-wrapped onto an indented line. A real triad after
|
|
201
|
+
an introductory comma ("in a word, fast, cheap, and simple") is now
|
|
202
|
+
missed too: a regex cannot tell a list comma from any other comma.
|
|
203
|
+
- `negative-subject`'s frames now accept a clause break (a comma before a
|
|
204
|
+
coordinating conjunction, or a bare comma, semicolon or colon) as well
|
|
205
|
+
as the end of the sentence, so a real clause followed by more of the
|
|
206
|
+
same sentence still matches; and its relative-clause tail now allows one
|
|
207
|
+
to ten words instead of at most three, so a real "that does not X" tail
|
|
208
|
+
isn't cut short.
|
|
209
|
+
|
|
84
210
|
## [0.9.0] - 2026-09-21
|
|
85
211
|
|
|
86
212
|
### Added
|
data/README.md
CHANGED
|
@@ -1,18 +1,48 @@
|
|
|
1
1
|
# sloplint
|
|
2
2
|
|
|
3
|
-
A dependency-free CLI that scans prose for the tells of AI-generated **slop** and reports them as
|
|
3
|
+
A dependency-free CLI that scans prose for the tells of AI-generated **slop** and reports them as notes. Think `proselint`, but aimed narrowly at the tics and puffery that mark LLM writing: the `no X, no Y` chains, the `rich tapestry of`, the `some critics argue` hedging. It writes JSON an agent can act on and text a person can read.
|
|
4
4
|
|
|
5
|
-
The primary reader is an agent (Claude Code and friends) that runs sloplint, reads the JSON, and rewrites what it flags.
|
|
5
|
+
The primary reader is an agent (Claude Code and friends) that runs sloplint, reads the JSON, and rewrites what it flags. A human is the secondary reader, and I keep the false-positive rate low enough that a note is worth trusting.
|
|
6
6
|
|
|
7
|
-
|
|
7
|
+
## Quick start
|
|
8
8
|
|
|
9
|
-
|
|
9
|
+
The one recipe to learn, and the one an agent should use:
|
|
10
|
+
|
|
11
|
+
```bash
|
|
12
|
+
cat draft.md | sloplint check --markdown -o json -
|
|
13
|
+
```
|
|
14
|
+
|
|
15
|
+
`--markdown` blanks out code, HTML comments, and URLs first, `-o json` gives the JSON output, and `-` reads stdin.
|
|
16
|
+
|
|
17
|
+
Exit 0 means clean, 1 means notes found, anything higher is an error. `check` is the default command, so `sloplint draft.md`, `sloplint -`, and a bare `sloplint` with piped stdin all scan.
|
|
10
18
|
|
|
11
|
-
|
|
19
|
+
One flag more, `--judge`, adds a second catalog of rules that ask a model what no pattern can match. [The judge](#the-judge-the-tells-a-regex-cannot-reach) sets that up, and it is the only part of sloplint that sends your text anywhere.
|
|
20
|
+
|
|
21
|
+
The human output drops `-o json`:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
$ printf 'The report is a rich tapestry of vibrant detail.\nThat is exactly the point I keep making about it.\n' | sloplint check -
|
|
25
|
+
-:1:17: error (high confidence) rich-tapestry "rich tapestry"/"tapestry of" is a signature AI cliché.
|
|
26
|
+
excerpt: The report is a [rich tapestry] of vibrant detail. That is exactly the…
|
|
27
|
+
why: 'tapestry of' is one of the most reliable single-phrase model tells.
|
|
28
|
+
fix: Cut the metaphor; name the actual things.
|
|
29
|
+
|
|
30
|
+
-:1:34: error (high confidence) puffery-words Wikipedia-style puffery word/phrase — a common AI tell.
|
|
31
|
+
excerpt: The report is a rich tapestry of [vibrant] detail. That is exactly the point I…
|
|
32
|
+
why: Travel-brochure adjectives and phrases that models reach for and careful writers avoid.
|
|
33
|
+
fix: Replace with a concrete, specific detail or cut it.
|
|
34
|
+
|
|
35
|
+
-:2:1: info (medium confidence) exact-exactly "exact/exactly" is reflexive emphasis unless it names something checkable.
|
|
36
|
+
excerpt: …is a rich tapestry of vibrant detail. [That is exactly] the point I keep making about it.
|
|
37
|
+
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.
|
|
38
|
+
fix: Cut it, or replace with the number, name, or match it's supposed to be precise about.
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
The brackets mark the match; the rest lets you see what you're fixing without opening the file, *why* included, so an agent has the rationale without a second command. The confidence is how likely the note is right; a `low` note is a place to look, not a finding.
|
|
12
42
|
|
|
13
43
|
## Installation
|
|
14
44
|
|
|
15
|
-
Requires **Ruby 3.3+** and nothing else
|
|
45
|
+
Requires **Ruby 3.3+** and nothing else: it runs on the standard library alone (`optparse`, `json`, native regex). The Ruby that macOS ships at `/usr/bin/ruby` is 2.6 and too old; `brew install ruby` gets you a current one.
|
|
16
46
|
|
|
17
47
|
### In Claude Cowork or the Claude desktop app
|
|
18
48
|
|
|
@@ -22,13 +52,15 @@ Open **Customize → Plugins → Add marketplace** and paste exactly this:
|
|
|
22
52
|
benjaminjackson/sloplint
|
|
23
53
|
```
|
|
24
54
|
|
|
25
|
-
|
|
55
|
+
Then install **sloplint** from the list.
|
|
56
|
+
|
|
57
|
+
Paste those two words and nothing else: no `https://` on the front, no slash on the end. The full URL `https://github.com/benjaminjackson/sloplint` works too, but a URL copied from a page you were browsing usually carries a `/tree/main` or a tracking parameter on the end, and anything after the repository name fails.
|
|
26
58
|
|
|
27
|
-
|
|
59
|
+
This way needs no package manager at all, which is why it works in Cowork, where `gem install` cannot reach the internet. The plugin carries its own source and runs it in place.
|
|
28
60
|
|
|
29
|
-
New rules reach you when you click **Update** on the marketplace
|
|
61
|
+
New rules reach you when you click **Update** on the marketplace; nothing updates on its own.
|
|
30
62
|
|
|
31
|
-
|
|
63
|
+
Now ask Claude to check a draft, or run `/sloplint:check`.
|
|
32
64
|
|
|
33
65
|
### In Claude Code
|
|
34
66
|
|
|
@@ -40,7 +72,8 @@ Once it is installed, ask Claude to check a draft, or run `/sloplint:check`.
|
|
|
40
72
|
### In a terminal
|
|
41
73
|
|
|
42
74
|
```bash
|
|
43
|
-
gem install sloplint
|
|
75
|
+
gem install sloplint # the regex rules, offline and free
|
|
76
|
+
gem install sloplint-judge # optional, the judge rules behind --judge
|
|
44
77
|
```
|
|
45
78
|
|
|
46
79
|
Or build from source:
|
|
@@ -52,39 +85,29 @@ gem build sloplint.gemspec
|
|
|
52
85
|
gem install ./sloplint-*.gem
|
|
53
86
|
```
|
|
54
87
|
|
|
55
|
-
Either way
|
|
88
|
+
Either way you get a `sloplint` executable on your path.
|
|
56
89
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
The recipe sloplint is built around, and the one an agent should use:
|
|
90
|
+
### In a Ruby project
|
|
60
91
|
|
|
61
|
-
```
|
|
62
|
-
|
|
92
|
+
```ruby
|
|
93
|
+
gem "sloplint"
|
|
94
|
+
gem "sloplint-judge" # optional, the judge rules
|
|
63
95
|
```
|
|
64
96
|
|
|
65
|
-
|
|
97
|
+
That gives you `bundle exec sloplint check ...` and a library. The engine is a plain method over a string, so one call scans what a model wrote before it reaches a user:
|
|
66
98
|
|
|
67
|
-
|
|
99
|
+
```ruby
|
|
100
|
+
require "sloplint"
|
|
68
101
|
|
|
102
|
+
notes = Sloplint::Engine.scan(draft, markdown: true)
|
|
103
|
+
notes.map(&:to_h) # the same hashes -o json prints
|
|
69
104
|
```
|
|
70
|
-
$ printf 'The report is a rich tapestry of vibrant detail.\nThat is exactly the point I keep making about it.\n' | sloplint check -
|
|
71
|
-
-:1:17: error rich-tapestry "rich tapestry"/"tapestry of" is a signature AI cliché.
|
|
72
|
-
excerpt: The report is a [rich tapestry] of vibrant detail. That is exactly the…
|
|
73
|
-
why: 'tapestry of' is one of the most reliable single-phrase model tells.
|
|
74
|
-
fix: Cut the metaphor; name the actual things.
|
|
75
105
|
|
|
76
|
-
|
|
77
|
-
excerpt: The report is a rich tapestry of [vibrant] detail. That is exactly the point I…
|
|
78
|
-
why: Travel-brochure adjectives and phrases that models reach for and careful writers avoid.
|
|
79
|
-
fix: Replace with a concrete, specific detail or cut it.
|
|
106
|
+
`Sloplint::Judge::Engine.scan(draft, markdown: true)` runs the judge rules. In a request path they cost what the regex rules do not: a network call to TypeSafe, a second or two per document, and money per run. The regex rules cost none of that, so reach for them inline and keep the judge rules for a background job, a review step or a test. If all you want is the command, `require: false` in a `:development` group keeps both out of the app.
|
|
80
107
|
|
|
81
|
-
|
|
82
|
-
excerpt: …is a rich tapestry of vibrant detail. [That is exactly] the point I keep making about it.
|
|
83
|
-
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.
|
|
84
|
-
fix: Cut it, or replace with the number, name, or match it's supposed to be precise about.
|
|
85
|
-
```
|
|
108
|
+
## What it catches, and what it doesn't
|
|
86
109
|
|
|
87
|
-
|
|
110
|
+
A pattern earns a place in the catalog only if it shows up constantly in AI writing and rarely in careful human writing. Passive voice, weak adverbs, wordiness, clichés a person reaches for too: they belong in `proselint` or `write-good`, not here. sloplint hunts the tells of a language model, so an agent can act on a note instead of second-guessing it.
|
|
88
111
|
|
|
89
112
|
## Commands
|
|
90
113
|
|
|
@@ -98,14 +121,15 @@ explain ID print one rule's message, rationale, and a bad/ok example
|
|
|
98
121
|
version print the sloplint version
|
|
99
122
|
```
|
|
100
123
|
|
|
101
|
-
`check` takes
|
|
124
|
+
`check` takes paths; `-`, or nothing, reads stdin. The options:
|
|
102
125
|
|
|
103
|
-
- `--markdown`
|
|
104
|
-
- `--select IDS` runs only these rules.
|
|
126
|
+
- `--markdown` replaces fenced code, inline code, HTML comments, and URLs with same-length whitespace before scanning, so line and column stay correct. Without it, sloplint treats the file as prose and flags text inside your code fences. Off by default, so it never silently eats prose.
|
|
127
|
+
- `--select IDS` runs only these rules. Comma-separated rule ids or category names.
|
|
105
128
|
- `--ignore IDS` skips these rules. Same id-or-category form.
|
|
106
|
-
- `--strict` runs every rule, including the
|
|
129
|
+
- `--strict` runs every rule, including the low-confidence ones that are off by default. Their notes are places to look, not findings. `--ignore` still applies.
|
|
130
|
+
- `--judge` also runs the judge rules, which need the `sloplint-judge` gem and a key: see [The judge](#the-judge-the-tells-a-regex-cannot-reach).
|
|
107
131
|
|
|
108
|
-
|
|
132
|
+
An agent calls `explain` to decide whether a note is worth acting on:
|
|
109
133
|
|
|
110
134
|
```
|
|
111
135
|
$ sloplint explain no-x-no-y
|
|
@@ -120,73 +144,43 @@ Flags: No fluff, no filler, no jargon.
|
|
|
120
144
|
Does not: No parking on Sundays.
|
|
121
145
|
```
|
|
122
146
|
|
|
123
|
-
|
|
147
|
+
## The judge: the tells a regex cannot reach
|
|
124
148
|
|
|
125
|
-
|
|
149
|
+
Everything above is sloplint's 83 **regex rules**: patterns matched against the text, offline, in milliseconds, for nothing. Some tells have no pattern to match. A paragraph that names nothing a reader could check, a sentence that tells its reader what they already know: catching those takes something that can read.
|
|
126
150
|
|
|
127
|
-
|
|
151
|
+
That is the judge, a second catalog of 20 **judge rules** which are questions put to a model rather than patterns. It ships as a companion gem, it turns on with `--judge`, and it needs an API key. Both catalogs write the same notes into the same JSON, so whatever already reads sloplint's output reads the judge's without a change.
|
|
128
152
|
|
|
129
|
-
|
|
153
|
+
### Install it and get a key
|
|
130
154
|
|
|
131
|
-
|
|
132
|
-
{
|
|
133
|
-
"path": "draft.md",
|
|
134
|
-
"line": 12,
|
|
135
|
-
"column": 5,
|
|
136
|
-
"severity": "warning",
|
|
137
|
-
"confidence": "high",
|
|
138
|
-
"rule": "no-x-no-y",
|
|
139
|
-
"category": "cadence",
|
|
140
|
-
"message": "\"No X, no Y\" chain (3 items) reads as AI cadence.",
|
|
141
|
-
"excerpt": "No fluff, no filler, no jargon",
|
|
142
|
-
"context": "The report was blunt. [No fluff, no filler, no jargon]. Nothing held back at all.",
|
|
143
|
-
"count": 3,
|
|
144
|
-
"rationale": "Asyndetic negation chains are a signature model cadence, near-absent from human prose at any length. A careful writer occasionally stacks two (and, rarely, more), but a model reaches for the pattern constantly.",
|
|
145
|
-
"suggestion": "Cut the chain or make it one plain sentence."
|
|
146
|
-
}
|
|
147
|
-
```
|
|
148
|
-
|
|
149
|
-
`severity` is what the construct costs the prose; `confidence` is how likely the match is a false positive. The two are separate, so a cheap tell we are sure about and an expensive one we are guessing at no longer collapse into the same word. `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 a flag is worth acting on doesn't have to run `explain` separately to find out. `suggestion` is a short fix hint.
|
|
150
|
-
|
|
151
|
-
## Exit codes
|
|
152
|
-
|
|
153
|
-
Four codes carry the contract. A crash exits nonzero on its own.
|
|
154
|
-
|
|
155
|
-
| code | meaning |
|
|
156
|
-
|------|---------|
|
|
157
|
-
| 0 | ran, no notes |
|
|
158
|
-
| 1 | ran, notes found |
|
|
159
|
-
| 2 | bad arguments or usage error |
|
|
160
|
-
| 3 | `--judge` only: the model could not be reached, no notes written |
|
|
155
|
+
`gem install sloplint-judge` adds the rules; they need a key for TypeSafe's API, which the regex rules never do. Jev, the model the judge asks, is in early access behind a waitlist: sign up at [typesafe.ai](https://typesafe.ai), and once you are through, issue a key at [console.typesafe.ai](https://console.typesafe.ai/settings/keys). TypeSafe meters input tokens only, $42 per billion, so a 2,000-word document costs well under a cent.
|
|
161
156
|
|
|
162
|
-
|
|
157
|
+
Put the key in one of two places. The keychain, which `key set` writes and the judge reads by default:
|
|
163
158
|
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
One thing is different from the rest of sloplint: the judge sends your text to an API. sloplint on its own never leaves the machine. Every paragraph the judge examines goes to `api.typesafe.ai` over HTTPS, and nothing goes anywhere until you set a key, so the plain `sloplint check` stays offline whether or not the judge is installed.
|
|
159
|
+
```bash
|
|
160
|
+
sloplint-judge key set # the keychain tool prompts, with echo off
|
|
161
|
+
```
|
|
169
162
|
|
|
170
|
-
|
|
163
|
+
Or the environment, which beats the keychain:
|
|
171
164
|
|
|
172
165
|
```bash
|
|
173
|
-
|
|
174
|
-
sloplint-judge key set # stores your TypeSafe API key in the OS keychain; it prompts for it
|
|
166
|
+
export TYPESAFE_API_KEY=...
|
|
175
167
|
```
|
|
176
168
|
|
|
177
|
-
|
|
169
|
+
The keychain is for a workstation. On a server, use the environment, or whatever secret manager fills the environment.
|
|
178
170
|
|
|
179
|
-
|
|
171
|
+
`sloplint-judge status` says whether it found a key and where, without printing it, and `key unset` removes the keychain item. [docs/JUDGE.md](docs/JUDGE.md#where-the-key-lives) has the rest, including what the keychain protects you from and what it doesn't.
|
|
180
172
|
|
|
181
|
-
|
|
173
|
+
A key makes the judge possible; it does not make the judge run. In Claude Code and Cowork the plugin ships with the judge, and the `/sloplint:check` skill asks once per conversation before it runs the judge. It names what leaves the machine and what the run costs, and it stays offline without a yes. On the command line, `--judge` is consent.
|
|
182
174
|
|
|
183
|
-
|
|
175
|
+
### Turn it on
|
|
176
|
+
|
|
177
|
+
One flag adds the judge rules to the same run:
|
|
184
178
|
|
|
185
179
|
```bash
|
|
186
|
-
sloplint check --judge --markdown -o json
|
|
180
|
+
cat draft.md | sloplint check --judge --markdown -o json -
|
|
187
181
|
```
|
|
188
182
|
|
|
189
|
-
|
|
183
|
+
The notes merge into one array in document order, so nothing downstream has to know which catalog found what. Because the judge costs money, the run says how much: the notes move under a `notes` key and a `judge` key carries the backend, the request count, tokens and dollars. The same figures go to stderr in the human output.
|
|
190
184
|
|
|
191
185
|
```json
|
|
192
186
|
{
|
|
@@ -195,9 +189,19 @@ That runs both catalogs and merges the notes in document order. Because the judg
|
|
|
195
189
|
}
|
|
196
190
|
```
|
|
197
191
|
|
|
198
|
-
|
|
192
|
+
`--judge` is the only flag that sends your text off the machine. Installing the judge gem does not opt you in; without the flag, nothing leaves.
|
|
193
|
+
|
|
194
|
+
### Options, failures, and the second executable
|
|
195
|
+
|
|
196
|
+
`--judge` adds one option of its own:
|
|
199
197
|
|
|
200
|
-
The
|
|
198
|
+
- `--register TEXT` says who the reader is. The judge asks every question on that reader's behalf, so a rule like `no-news` flags a sentence *that* reader already knows rather than one anybody would know. The default is "the reader this document is written for", which leaves the model to infer the audience from the text. Name the reader when you know it: `--register "a developer deciding whether to install this tool, reading its README"` scores a README against the person it is written for.
|
|
199
|
+
|
|
200
|
+
`--strict` does more under `--judge`: on top of the regex rules that are off by default, it runs the judge rules that are off by default, asks the sentence questions of every sentence rather than only the flagged paragraphs, and keeps answers the model was unsure of, which come through at `low`: places to look, not findings.
|
|
201
|
+
|
|
202
|
+
Without the gem, `--judge` exits 2 and names the install command. Without a key, it exits 2 and names the variable to set. If the model cannot be reached, or answers in a shape the judge does not understand, it exits 3 and writes nothing, not even the regex notes, so a partial run can never pass as a clean one.
|
|
203
|
+
|
|
204
|
+
The judge gem also puts a `sloplint-judge` executable on your path, for running the judge rules alone, managing the key, and comparing two passages:
|
|
201
205
|
|
|
202
206
|
```
|
|
203
207
|
sloplint-judge [-o full|json] [--register TEXT] [--backend NAME] [command] [args]
|
|
@@ -212,62 +216,91 @@ key unset remove it from the OS keychain
|
|
|
212
216
|
version print the sloplint-judge version
|
|
213
217
|
```
|
|
214
218
|
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
### The rules
|
|
219
|
+
The backend, the model name, the endpoint and the concurrency all come from the environment. [docs/JUDGE.md](docs/JUDGE.md) has the table of variables, the design, the calibration that decides which rules ship, and how to add a backend.
|
|
218
220
|
|
|
219
|
-
|
|
221
|
+
## The note
|
|
220
222
|
|
|
221
|
-
|
|
222
|
-
- **sentence** (8): `stock-figure`, a stock figure of speech; `no-news`, a sentence that explains what the stated reader already knows; `names-nothing`, a sentence with no specific noun in it; `ends-on-verdict`, a sentence that ends by grading the fact it just stated; `trailing-gloss`, a sentence that ends on an -ing clause drawing its own moral; `unnamed-authority`, a claim handed to experts, studies or many; `stated-stakes`, a sentence that says something matters and not why; `matched-shape`, a pair or triple built to a rhythm rather than to the content. `stated-stakes` and `matched-shape` are off by default; name them in `--select` or pass `--strict`.
|
|
223
|
+
One match is one note. JSON output is an array of notes, or an object keyed by path when you scan more than one file. Under `--judge` it moves beneath a `notes` key, beside a `judge` key with the backend name, request count and token counts (see [`--judge`](#--judge)). The schema is the contract:
|
|
223
224
|
|
|
224
|
-
|
|
225
|
+
```json
|
|
226
|
+
{
|
|
227
|
+
"path": "draft.md",
|
|
228
|
+
"line": 12,
|
|
229
|
+
"column": 5,
|
|
230
|
+
"severity": "warning",
|
|
231
|
+
"confidence": "high",
|
|
232
|
+
"rule": "no-x-no-y",
|
|
233
|
+
"category": "cadence",
|
|
234
|
+
"message": "\"No X, no Y\" chain (3 items) reads as AI cadence.",
|
|
235
|
+
"excerpt": "No fluff, no filler, no jargon",
|
|
236
|
+
"context": "The report was blunt. [No fluff, no filler, no jargon]. Nothing held back at all.",
|
|
237
|
+
"count": 3,
|
|
238
|
+
"rationale": "Asyndetic negation chains are a signature model cadence, near-absent from human prose at any length. A careful writer occasionally stacks two (and, rarely, more), but a model reaches for the pattern constantly.",
|
|
239
|
+
"suggestion": "Cut the chain or make it one plain sentence."
|
|
240
|
+
}
|
|
241
|
+
```
|
|
225
242
|
|
|
226
|
-
|
|
243
|
+
`severity` and `confidence` are the two ratings [the catalog](#the-rule-catalog) explains. `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, useful 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. `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. `suggestion` is a one-line fix.
|
|
227
244
|
|
|
228
|
-
|
|
245
|
+
## Exit codes
|
|
229
246
|
|
|
230
|
-
|
|
247
|
+
The exit code is the contract. A crash exits nonzero outside these four.
|
|
231
248
|
|
|
232
|
-
|
|
|
233
|
-
|
|
234
|
-
|
|
|
235
|
-
|
|
|
236
|
-
|
|
|
237
|
-
|
|
|
238
|
-
| `SLOPLINT_JUDGE_CONCURRENCY` | `8` | parallel requests |
|
|
249
|
+
| code | meaning |
|
|
250
|
+
|------|---------|
|
|
251
|
+
| 0 | ran, no notes |
|
|
252
|
+
| 1 | ran, notes found |
|
|
253
|
+
| 2 | bad arguments or usage error |
|
|
254
|
+
| 3 | `--judge` only: the model could not be reached, no notes written |
|
|
239
255
|
|
|
240
|
-
|
|
256
|
+
An unknown id or category in `--select`/`--ignore` is a usage error (exit 2, naming the id) rather than a silent no-op, so a typo can't masquerade as a clean scan. Empty or whitespace-only input is exit 2 for the same reason: a pipe that delivered nothing must not read as a clean draft. That applies only when every source is empty; one empty file among several is deliberate, and sloplint scans the rest.
|
|
241
257
|
|
|
242
258
|
## The rule catalog
|
|
243
259
|
|
|
244
|
-
|
|
260
|
+
The [judge rules](#the-judge-rules) follow these.
|
|
261
|
+
|
|
262
|
+
84 rules across nine categories, each named for the rhetorical move the construct makes. `sloplint rules` prints them; `sloplint rules --json` gives an agent the enumerable form.
|
|
245
263
|
|
|
246
|
-
- **self-rating** (16) the writer grades their own prose or claim: `clean-x`, `clean-count`, `cleanest-x`, `cleanly`, `honest-x`, `most-honest-x`, `honestly` (the honesty family, built the same way as the four `clean` rules), `worth-naming`, `worth-saying-plainly`, `earns-its-place`, `does-a-lot-of-work`, `exact-exactly`, `genuinely` (off by default), `the-punchline-is`, `announced-takeaway`, `cataphoric-teaser` (
|
|
247
|
-
- **closer** (12) closes by restating or announcing the point: `thats-the-whole`, `is-the-whole-x` (the same closer on any subject:
|
|
248
|
-
- **cadence** (
|
|
249
|
-
- **puffery** (8) inflates the subject: `puffery-words` (vibrant
|
|
250
|
-
- **false-correction** (8) corrects a reading nobody offered: `not-just-x-but-y`, `not-x-but-y` (the bare corrective), `not-by-x-but-by-y` (
|
|
251
|
-
- **false-concession** (7) performs balance or candour and gives nothing up: `two-things-true`, `none-of-this-is-to-say`, `is-real-and-not`, `not-nothing`, `vague-attribution` (
|
|
252
|
-
- **reader-address** (6) instructs or flatters the reader: `you-already-know`, `sit-with-that`, `hold-onto-that`, `notice-what`, `notice-what-there`, `quip-question` (the verbless
|
|
253
|
-
- **borrowed-metaphor** (5) an engineering term applied to an argument: `load-bearing`, `failure-mode-here`, `intersection-of`, `impact-verb` (
|
|
264
|
+
- **self-rating** (16) the writer grades their own prose or claim: `clean-x`, `clean-count`, `cleanest-x`, `cleanly`, `honest-x`, `most-honest-x`, `honestly` (the honesty family, built the same way as the four `clean` rules), `worth-naming`, `worth-saying-plainly`, `earns-its-place`, `does-a-lot-of-work`, `exact-exactly`, `genuinely` (off by default), `the-punchline-is`, `announced-takeaway`, `cataphoric-teaser` (`Here's what nobody tells you`, `the part most people get wrong`).
|
|
265
|
+
- **closer** (12) closes by restating or announcing the point: `thats-the-whole`, `is-the-whole-x` (the same closer on any subject: `Consistency is the real test.`, at `medium` confidence), `is-the-entire`, `the-entire-is`, `thats-how-x`, `thats-the-tension`, `right-up-until`, `and-nothing-else` (the trailing `…, and nothing else`), `nothing-else-frag`, `bare-equative` (`The lesson is the handoff.`, at `medium` confidence), `trailing-restatement` (the `…, which means …` tail that says the sentence again, off by default), `and-what-it-should` (the elliptical tail: `…, and what it should.`).
|
|
266
|
+
- **cadence** (20) rhythm: repetition, parallelism, and the long-then-short kicker: `no-x-no-y`, `no-x-no-y-frag`, `did-not-x-did-not-y`, `one-x-one-y` (`one reviewer, one queue, one deadline`), `from-x-to-y-chain` (`from guessing to measuring, from hoping to knowing`), `same-determiner-chain` (any other repeated determiner, at `medium` confidence), `real-x-real-y`, `epistrophe` (off by default), `phrase-echo` (off by default), `is-is` (doubled copula), `the-x-is-the-x` (`the problem with A is the problem with B`), `the-x-is-not-the-x` (any noun split in two by a negated copula: `the job you applied for is not the job you will do`, at `medium` confidence), `rule-of-three` (off by default), `everyone-nobody` (the comma-spliced antithesis: `Everyone wants the dashboard, nobody maintains it.`), `short-run` (three sentences of thirty characters or fewer in a row, at `medium` confidence), `mic-drop-closer` (the short quantifier-led sentence that ends a paragraph after a long one, at `medium` confidence), `bare-auxiliary-closer` (the same shape, but the closer's verb is elided down to a bare auxiliary: `The agent did.`, at `medium` confidence), `np-fragment-and` (the verbless `A named owner and a quarterly review.`, at `medium` confidence), `punch-sentence` (the verbless beat of three words or fewer between two long sentences, `Not anymore.`, at `medium` confidence), `negative-subject` (a negative pronoun as the subject of a clause, dressed up with a paired clause or its own negated relative clause: `nothing gets merged that does not get reviewed.`, at `medium` confidence).
|
|
267
|
+
- **puffery** (8) inflates the subject: `puffery-words` (`vibrant`, `nestled`, `groundbreaking`, `in the heart of`), `rich-tapestry`, `vital-role`, `stands-serves-as`, `underscores-highlights`, `impact-noun-vague` (`a significant impact`, `make an impact`), `trailing-significance-participle` (the `…, showcasing its importance` clause), `abstract-lives-in` (`the value sits in the follow-up`, at `medium` confidence).
|
|
268
|
+
- **false-correction** (8) corrects a reading nobody offered: `not-just-x-but-y`, `not-x-but-y` (the bare corrective), `not-by-x-but-by-y` (`not by luck, but by design`), `isnt-x-its-y` (the same corrective split across two clauses: `It isn't the tool. It's the habit.`), `question-isnt` (the corrective frame in interrogative dress), `less-about-more-about`, `actually-not-x`, `dont-verb-it`.
|
|
269
|
+
- **false-concession** (7) performs balance or candour and gives nothing up: `two-things-true`, `none-of-this-is-to-say`, `is-real-and-not`, `not-nothing`, `vague-attribution` (`some critics argue`, `it is widely regarded`), `if-im-being-honest` (the candor preamble, from slopwash.com's `false intimacy`), `and-thats-fine`.
|
|
270
|
+
- **reader-address** (6) instructs or flatters the reader: `you-already-know`, `sit-with-that`, `hold-onto-that`, `notice-what`, `notice-what-there`, `quip-question` (the verbless `No invite?`, at `medium` confidence).
|
|
271
|
+
- **borrowed-metaphor** (5) an engineering term applied to an argument: `load-bearing`, `failure-mode-here`, `intersection-of`, `impact-verb` (`the outage impacted four thousand accounts`), `impact-noun-bare` (`the impact of X`, at `medium` confidence because research prose uses it straight).
|
|
254
272
|
- **punctuation** (2) the mark itself: `em-dash` (any em dash), `em-dash-overuse` (three or more in one paragraph).
|
|
255
273
|
|
|
256
|
-
Every rule carries two ratings
|
|
274
|
+
Every rule carries two ratings. **Severity** is what the tell costs the prose: `error` when the sentence is worse for it in any register (`rich-tapestry`, `puffery-words`, `vague-attribution`), `warning` when it dates the draft as model output but the sentence still says something, `info` when it is harmless but worth knowing about (`em-dash`). **Confidence** is how likely the match is a true hit: `high` when the pattern almost never misfires, `medium` when ordinary prose produces the same shape often enough that an agent should read the rationale before acting, `low` when the pattern cannot separate the tell from ordinary use.
|
|
275
|
+
|
|
276
|
+
The two move independently: `rich-tapestry`, `thats-the-whole` and `cleanest-x` are `error` wherever they appear, whatever my confidence in the match.
|
|
277
|
+
|
|
278
|
+
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 wrote; `no-x-no-y-frag` takes the same cadence built from sentence fragments, which ordinary prose also produces, so it ships at `medium` confidence. Same with `not-just-x-but-y` and `not-x-but-y`, and with `notice-what-there` and `notice-what`. The quiet half still deserves a note, since an agent that reads the rationale can judge for itself, but it should not carry the same weight as the half I'm sure about. `and-nothing-else` and `nothing-else-frag` are also a pair, and both sit at `high`. The fragment half carries a capital letter and a whole-sentence requirement which the comma half lacks, so it is the narrower of the two rather than the quieter one.
|
|
279
|
+
|
|
280
|
+
The `low` rules are **off by default**, because each one matches a shape ordinary prose produces on its own:
|
|
281
|
+
|
|
282
|
+
- `rule-of-three` flags a comma series of single words closing a sentence, which writers of every kind produce constantly. The narrowings are that the last two items must be single words, so three phrases do not match, and that the first word cannot follow a comma, so the tail of a longer list does not match either. A series of three words is still often an ordinary list, and no regex sees the difference.
|
|
283
|
+
- `genuinely` flags every occurrence of the word. As an intensifier it rates the writer's sincerity, but it still does real work when it draws a contrast, and nothing in the sentence separates the two.
|
|
284
|
+
- `epistrophe` flags two clauses ending on the same phrase: a named figure careful writers use on purpose, and one ordinary prose produces by reusing a phrase.
|
|
285
|
+
- `trailing-restatement` flags the "…, which means …" tail and the participles that hang a result off the sentence ("…, making it easier"). The connective is visible and the restatement is not, so a real consequence trips it too.
|
|
286
|
+
- `phrase-echo` flags a three-word phrase that comes back within a few hundred words. A term of art comes back because it must, and the pattern cannot tell one from a phrase the writer coined.
|
|
287
|
+
|
|
288
|
+
They run when you name them (`sloplint check --select rule-of-three -`), or when you pass `--strict`. Naming a category in `--select` turns on that category's other rules only; naming a rule's id runs it regardless. `--strict` on its own runs the whole catalog, but alongside `--select` it only widens the named categories to include their low-confidence rules. `sloplint rules --json` lists every rule's `severity`, `confidence` and `rationale`, so an agent can tell which rules are off by default without reading this file.
|
|
257
289
|
|
|
258
|
-
The
|
|
290
|
+
### The judge rules
|
|
259
291
|
|
|
260
|
-
|
|
292
|
+
Twenty rules in two categories. They run only under `--judge`, because each one costs a request to a model; `sloplint-judge rules` lists them and `sloplint-judge explain ID` prints the question it asks the model, the answer that flags, and the fixtures. The bar differs from the regex catalog's: a judge rule ships when a reader, shown the flagged unit, agrees it should go, whoever wrote it. Severity comes from how sharply the rule separates model prose from human prose. So `throat-clearing` is `info`, not gone: human abstracts open by announcing the paper, and the announcement is dead weight either way.
|
|
261
293
|
|
|
262
|
-
|
|
294
|
+
- **paragraph** (6): `particulars`, names nothing a reader could check; `wrap-up`, ends on a summary, a moral or a hope; `throat-clearing`, opens by announcing its topic; `self-narration`, signposts the document instead of advancing it; `promotional`, praises its subject and measures nothing; `same-weight`, states its guesses as flatly as its measurements.
|
|
295
|
+
- **sentence** (14): `stock-figure`, a stock figure of speech; `no-news`, explains what the reader you declared already knows; `names-nothing`, names nothing specific, and nothing in its paragraph makes its claim checkable; `ends-on-verdict`, ends by grading the fact it just stated; `trailing-gloss`, ends on an -ing clause drawing its own moral; `unnamed-authority`, hands a claim to experts, studies, "many people" or what is "known"; `stated-stakes`, says something matters without saying why; `matched-shape`, a pair or triple built to a rhythm rather than to content; `mirrored-opposites`, opposite words that make two separate claims read as a contrast; `positive-form`, says what something is not where it could say what it is; `name-the-thing`, points at something by position, stand-in or allusion instead of naming it; `maxim`, states a general saying where the fact about this case belongs; `dressed-pair`, one fact dressed as a pair, by an echoed verb or a "both" handed a verdict; `device-before-claim`, makes its claim through a mirror, a riddle, a held-back answer, a coined figure or a maxim instead of stating it plainly.
|
|
263
296
|
|
|
264
|
-
|
|
297
|
+
`same-weight`, `stated-stakes`, `matched-shape`, `mirrored-opposites`, `positive-form`, `name-the-thing` and `dressed-pair` are off by default; name them in `--select` or pass `--strict`.
|
|
265
298
|
|
|
266
|
-
|
|
299
|
+
Each note's `confidence` is the lower of two numbers: the rule's ceiling, and the model's confidence in that answer. The judge drops a note the model was unsure of unless you pass `--strict`, the same way sloplint drops its low-confidence rules.
|
|
267
300
|
|
|
268
301
|
## Adding a rule
|
|
269
302
|
|
|
270
|
-
Rules are data, not code. Each is a `
|
|
303
|
+
Rules are data, not code. Each is a `Rule.new` entry in `lib/sloplint/rules.rb`:
|
|
271
304
|
|
|
272
305
|
```ruby
|
|
273
306
|
Rule.new(
|
|
@@ -286,21 +319,21 @@ Rule.new(
|
|
|
286
319
|
)
|
|
287
320
|
```
|
|
288
321
|
|
|
289
|
-
`rationale`
|
|
322
|
+
Write the `rationale` for the agent. `sloplint explain` prints it, and an agent reads it to decide whether a note is worth acting on, so say what the pattern catches and where it's weak.
|
|
290
323
|
|
|
291
|
-
Adding a rule is one entry plus its fixtures. `rules_spec.rb`
|
|
324
|
+
Adding a rule is one entry plus its fixtures. `rules_spec.rb` runs every rule against its fixtures: each `examples_bad` must produce a note, each `examples_ok` none. A rule without fixtures, or one whose regex is too greedy, fails the suite.
|
|
292
325
|
|
|
293
326
|
## Development
|
|
294
327
|
|
|
295
|
-
|
|
328
|
+
There is no `Gemfile`; install `rspec` and `rake` yourself (`gem install rspec rake`), then:
|
|
296
329
|
|
|
297
330
|
```bash
|
|
298
331
|
rake spec # or: rspec
|
|
299
332
|
```
|
|
300
333
|
|
|
301
|
-
`
|
|
334
|
+
`cli_spec.rb` covers exit codes, stdin, JSON schema, `--select` and `--ignore`. It also checks that `--markdown` skips code. A slop fixture in `spec/fixtures/` doubles as an integration check.
|
|
302
335
|
|
|
303
|
-
See [`docs/SPEC.md`](docs/SPEC.md) for the
|
|
336
|
+
See [`docs/SPEC.md`](docs/SPEC.md) for the design, including why sloplint is a fresh tool rather than a proselint extension.
|
|
304
337
|
|
|
305
338
|
## Author
|
|
306
339
|
|
data/docs/SPEC.md
CHANGED
|
@@ -146,7 +146,8 @@ check options:
|
|
|
146
146
|
--markdown skip fenced/inline code spans, HTML comments, and URLs
|
|
147
147
|
--select IDS only run these rules (comma-separated ids or categories)
|
|
148
148
|
--ignore IDS skip these rules
|
|
149
|
-
--strict run every rule, including the off-by-default ones
|
|
149
|
+
--strict run every rule, including the off-by-default ones;
|
|
150
|
+
their low-confidence notes are places to look, not findings
|
|
150
151
|
```
|
|
151
152
|
|
|
152
153
|
`check` is the default command. A first argument that is not a command name is
|
|
@@ -356,12 +357,15 @@ Rhythm: repetition, parallelism, and the long-then-short kicker.
|
|
|
356
357
|
- `phrase-echo` — the same three words again within about 400 words; backreference in a lookahead, so the match is the first occurrence; each word 4+ characters and lowercase-led, one of them 6+ letters with nothing but letters, so function-word runs, contractions, proper nouns and Title Case headings are out; a repeat that opens on a quote mark, backtick, emphasis marker or hyphen is out; the gap crosses paragraph breaks but not a list item, a table row or 80+ non-word characters; off by default; `low` confidence
|
|
357
358
|
- `is-is` — doubled copula: "what it is is …", "the thing is, is that …"; comma optional
|
|
358
359
|
- `the-x-is-the-x` — "the X … is the X …": the same abstract head noun on both sides of the copula; backreference; closed list of heads that cannot name an object (key, cost, unit are out); the clause between is capped at 50 chars and may not hold a comma, semicolon or colon; the second head must be followed by a preposition, determiner, quantifier, pronoun, plural noun or punctuation, so compounds are out; "isn't" counts
|
|
360
|
+
- `the-x-is-not-the-x` — "the X … is not the X …": any head noun on both sides of a negated copula (is not, isn't, was not, were not); backreference; the clause between is capped at 50 chars and may not hold sentence punctuation, a comma, semicolon or colon; "of" after the second head and "the same X" are out, since both name the difference; the-x-is-the-x's abstract heads are skipped so each sentence is reported once; `medium` confidence
|
|
359
361
|
- `rule-of-three` — three single-word comma items ending a sentence (heuristic; `low` confidence, off by default; runs under `--select` or `--strict` since it false-positives).
|
|
360
362
|
- `everyone-nobody` — the comma-spliced antithesis on quantifier subjects: one clause opens on everyone/everybody, the other on nobody/no one/none or "one N", joined by a bare comma, the second closing the sentence ("Everyone wants the dashboard, nobody maintains it."). A conjunction, a period, or the same subject twice is not a hinge; "one of/by/per", a measure, a proper noun, "none of which" and "none louder than" are not second subjects; clauses are capped at eighty characters.
|
|
361
363
|
- `short-run` — three consecutive sentences of thirty characters or fewer, each letter-led and closing on a full stop, no quotation marks or digits, no lone-letter labels (either case, though a possessive is not one) or abbreviations, starting at a real sentence boundary (never on a wrap continuation), crossing a hard wrap but not a paragraph break; `medium` confidence. A list marker may open a run but never sit inside one, so consecutive bullets are a list — the marker set covers glyphs, the literal "o" plain-text documents use as a bullet, and numbered and lettered items. Two whole-run exclusions keep document furniture out, each a property of the run rather than of any sentence in it: three single-word sentences in a row is a citation line ("Natl. Inst. Stand. Technol."), and three lowercase-led sentences in a row is transcribed speech ("we all get gas. we go to divert to Albany."). One single-word sentence is the archetypal kicker and stays, and a run that reaches a capital anywhere is prose, so identifier-initial writing ("npm was slow. git blame helped. We moved on.") is untouched. One is a question; a draft that repeats it is the tell.
|
|
362
364
|
- `mic-drop-closer` — a sentence of 60+ characters, then a paragraph-final closer of two to eight words opening on a **quantifier** (Nothing, Most, None, Everything, Everyone, Nobody, Then, Neither, Both): "Nothing here needs a new login."; `medium` confidence. The bare demonstratives (That, This, It) were in the list and are out: procedural writing ends a step with one as a matter of course ("This completes the roughing operations."). A blank line or the end of the text must follow the closer; both sentences may be hard-wrapped; whitespace runs in the long sentence are capped so blanked Markdown cannot make it, and the long-sentence prefix is an atomic group so an unpunctuated stretch cannot send it into catastrophic backtracking. The note points at the closer. One means nothing; a draft where it repeats is the tell.
|
|
363
365
|
- `bare-auxiliary-closer` — the same long-sentence-then-short-closer shape as `mic-drop-closer`, but the tell sits in the verb rather than the subject: the closer's verb phrase is elided down to a bare auxiliary with no object, "The agent did."; `medium` confidence. No subject list is needed — a one-to-three word subject runs straight into a bare `did/does/do/was/were/is/are/had/has/can/could/would/will/should/might/must` (contracted forms included) and a period, and the closer must be the last thing in the paragraph, same as `mic-drop-closer`, so a closing quotation mark after the period excludes quoted dialogue the same way `short-run` excludes it. A negative lookahead drops a closer that still holds "what", "that", "which", "who", "why" or "how", since those introduce a subordinate clause supplying its own complement rather than an elided one ("Nobody knew who did." asks who did it). Reuses `mic-drop-closer`'s long-sentence prefix rather than a second copy of it. One means nothing; a draft where it repeats is the tell.
|
|
364
|
-
- `punch-sentence` — the same long-then-short shape in the middle of a paragraph, where the closer rules refuse it: after the shared sixty-character prefix, a verbless beat of one to three words closing on a full stop, then one or two spaces and a sentence of forty or more characters on the same paragraph ("…three nights running that week. Not anymore. The job now checks…"); `info`, `medium` confidence. The beat is one of two closed shapes: a negator (Not, No, Never, Nothing, None) followed by up to two lowercase words, or a single capitalised word of five letters or more ("Simple."). The stop before the beat may not close a capitalised word of one to four letters or a lone lowercase letter, so a title, an initial or a case citation ("Mrs. Cadaver.", "John F. Kennedy.", "Swift v. Tyson.") never yields a one-word sentence. Short clauses with a subject ("He agrees.", "Dawes refuses.") are how plot summaries and news briefs move and are not matched; a one-word imperative in procedural prose ("Drain.") is, at about one per 25,000 words of recipes. A run of short sentences is `short-run`'s, and a paragraph-final beat is `mic-drop-closer`'s or `bare-auxiliary-closer`'s
|
|
366
|
+
- `punch-sentence` — the same long-then-short shape in the middle of a paragraph, where the closer rules refuse it: after the shared sixty-character prefix, a verbless beat of one to three words closing on a full stop, then one or two spaces and a sentence of forty or more characters on the same paragraph ("…three nights running that week. Not anymore. The job now checks…"); `info`, `medium` confidence. The beat is one of two closed shapes: a negator (Not, No, Never, Nothing, None) followed by up to two lowercase words, or a single capitalised word of five letters or more ("Simple."). The stop before the beat may not close a capitalised word of one to four letters or a lone lowercase letter, so a title, an initial or a case citation ("Mrs. Cadaver.", "John F. Kennedy.", "Swift v. Tyson.") never yields a one-word sentence. Short clauses with a subject ("He agrees.", "Dawes refuses.") are how plot summaries and news briefs move and are not matched; a one-word imperative in procedural prose ("Drain.") is, at about one per 25,000 words of recipes. A run of short sentences is `short-run`'s, and a paragraph-final beat is `mic-drop-closer`'s or `bare-auxiliary-closer`'s.
|
|
367
|
+
- `np-fragment-and` — a whole sentence made of two noun phrases and an "and", opening on A/An/One at a sentence start or after a list marker ("A named owner and a quarterly review."). One to three words a side, no auxiliary or modal anywhere (contractions included); a lexical verb is invisible, so "A car and a truck collided." flags, which is why it ships at `medium` confidence.
|
|
368
|
+
- `negative-subject` — a negative pronoun (Nothing, Nobody, No one, None) as the subject of a clause, where the clause also carries one of two structural markers rather than stopping at a bare subject and verb ("Nothing happened.", "No one knows." are not matched; a regex cannot tell a lexical verb from any other word, so the narrowing comes from what surrounds the subject). A clause starts at the beginning of the text, after `.;:!?` or a line break, or after a coordinating conjunction (and, but, or, so, yet, nor) — the one addition to `actually-not-x`'s clause-start set, since a conjunction can open a second clause inside one sentence; the other frame stays off the conjunction start, since "and" more often joins two noun phrases in one clause than two clauses. Either frame's clause-start may also open on one or two markdown emphasis characters (`*` or `_`), so a sentence set off in bold or italics is still read from its first word. What ends a frame is a full stop, or a clause break (a comma before a coordinating conjunction, or a bare comma, semicolon or colon): a flagged clause need not be the sentence's last. The two frames: two negative-subject clauses joined by "and", the first predicate's word count excluding "and" itself so an atomic group can't swallow the separator ("Nothing is deployed and nothing is queued."); a negative-subject clause whose object carries its own negated relative clause, the negation the full closed set of auxiliaries and contractions — does/do/did, has/have/had, is/are/was/were — with the first predicate excluding "that" for the same atomic-group reason as frame 1, and up to ten words between the negation and the clause end so a longer relative clause still closes the match ("nothing gets merged that does not get reviewed."); `medium` confidence. Two other frames were probed and cut: "No" plus a common noun ("No vendor holds a contract.") is indistinguishable from ordinary negation ("No installation is necessary.") and fired at or above the model rate on RAID; a bare pronoun with a prepositional phrase before the verb ("Nobody on the on-call page owns the service.") is indistinguishable from a real, specific absence with the PP naming its scope ("Nobody on the team has reviewed the migration yet.").
|
|
365
369
|
|
|
366
370
|
### puffery
|
|
367
371
|
|
data/lib/sloplint/cli.rb
CHANGED
|
@@ -68,9 +68,15 @@ module Sloplint
|
|
|
68
68
|
o.on("--select IDS", "Only run these rules (comma-separated rule ids or category names).") { |v| select = v.split(",").map(&:strip) }
|
|
69
69
|
o.on("--ignore IDS", "Skip these rules (comma-separated rule ids or category names).") { |v| ignore = v.split(",").map(&:strip) }
|
|
70
70
|
o.on("--strict", "Run every rule, including the ones that are off by default;",
|
|
71
|
-
"with --judge, also ask the sentence rules about every sentence, about three times the requests."
|
|
72
|
-
|
|
73
|
-
o.on("--
|
|
71
|
+
"with --judge, also ask the sentence rules about every sentence, about three times the requests.",
|
|
72
|
+
"It also reports low-confidence notes and answers: places to look, not findings.") { strict = true }
|
|
73
|
+
o.on("--judge", "Also run sloplint-judge's rules, which ask a model (needs the gem and a key).",
|
|
74
|
+
"`sloplint explain ID` explains their notes too.") { judge = true }
|
|
75
|
+
o.on("--register TEXT", "With --judge: who reads the document. The judge asks every question",
|
|
76
|
+
"on that reader's behalf, so the same sentence is news to one reader",
|
|
77
|
+
"and filler to another. Default: #{DEFAULT_REGISTER},",
|
|
78
|
+
"which leaves the model to guess. Name the reader when you know it, e.g.",
|
|
79
|
+
"\"a developer deciding whether to install this tool, reading its README\".") { |v| register = v }
|
|
74
80
|
o.on("--backend NAME", "With --judge: which model adapter to use.") { |v| backend = v }
|
|
75
81
|
# OptionParser answers -h itself when nobody else does, and it answers
|
|
76
82
|
# it on the real stdout and ends the process. This command prints to
|
|
@@ -185,7 +191,7 @@ module Sloplint
|
|
|
185
191
|
elsif judge_backend
|
|
186
192
|
begin
|
|
187
193
|
judged = Judge::Engine.scan_sources(sources, name: "sloplint: judge", err:, rules: judge_rules, backend: judge_backend,
|
|
188
|
-
markdown:, register: register ||
|
|
194
|
+
markdown:, register: register || DEFAULT_REGISTER, strict:)
|
|
189
195
|
# Exit 3 withholds the regex notes too: a caller that asked for both
|
|
190
196
|
# and got one would read it as a clean judge run.
|
|
191
197
|
rescue Judge::BackendError => e
|
|
@@ -197,7 +203,7 @@ module Sloplint
|
|
|
197
203
|
judge_usage = judged.usage
|
|
198
204
|
end
|
|
199
205
|
|
|
200
|
-
emit(all_notes, opts[:format], out:, by_path:, judge: judge_usage)
|
|
206
|
+
emit(all_notes, opts[:format], out:, by_path:, judge: judge_usage, err:, strict:)
|
|
201
207
|
all_notes.empty? ? 0 : 1
|
|
202
208
|
end
|
|
203
209
|
|
|
@@ -245,7 +251,15 @@ module Sloplint
|
|
|
245
251
|
sources
|
|
246
252
|
end
|
|
247
253
|
|
|
248
|
-
def emit(notes, format, out:, by_path:, judge: nil)
|
|
254
|
+
def emit(notes, format, out:, by_path:, judge: nil, err: nil, strict: false)
|
|
255
|
+
# An agent reads --help once and the notes one at a time, so the
|
|
256
|
+
# warning goes where it acts: at the top of the run, on stderr so JSON
|
|
257
|
+
# on stdout stays parseable.
|
|
258
|
+
low = notes.count { |n| n.confidence == "low" }
|
|
259
|
+
if strict && err && low.positive?
|
|
260
|
+
err.puts("#{low} of #{notes.size} notes are low confidence (--strict). They are places to look, " \
|
|
261
|
+
"not findings: do not rewrite on their word alone.")
|
|
262
|
+
end
|
|
249
263
|
if format == "json"
|
|
250
264
|
out.puts(Output.format_json(notes, by_path:, judge:))
|
|
251
265
|
else
|
|
@@ -293,8 +307,13 @@ module Sloplint
|
|
|
293
307
|
end
|
|
294
308
|
rule = RULES.find { |r| r.id == id }
|
|
295
309
|
unless rule
|
|
310
|
+
# `check --judge` prints the judge's notes too, so their ids have to
|
|
311
|
+
# explain from here as well, or a reader holding one is sent to a
|
|
312
|
+
# catalog that does not list it.
|
|
313
|
+
return Judge::CLI.run(["explain", id], out:, err:) if judge_rule?(id)
|
|
314
|
+
|
|
296
315
|
err.puts("sloplint: no such rule: #{id}")
|
|
297
|
-
err.puts("run `sloplint rules` to list them.")
|
|
316
|
+
err.puts("run `sloplint rules` to list them, and `sloplint-judge rules` for the judge's.")
|
|
298
317
|
return 2
|
|
299
318
|
end
|
|
300
319
|
out.puts(<<~TXT)
|
|
@@ -319,6 +338,15 @@ module Sloplint
|
|
|
319
338
|
examples.map { |e| e.gsub("\r", '\r').gsub("\n", '\n') }.join("\n ")
|
|
320
339
|
end
|
|
321
340
|
|
|
341
|
+
# True when the judge is installed and owns this id. A missing judge is
|
|
342
|
+
# not an error here: the id is then simply unknown.
|
|
343
|
+
def judge_rule?(id)
|
|
344
|
+
require "sloplint/judge/cli"
|
|
345
|
+
Judge::RULES.any? { |r| r.id == id }
|
|
346
|
+
rescue LoadError
|
|
347
|
+
false
|
|
348
|
+
end
|
|
349
|
+
|
|
322
350
|
# ── helpers ─────────────────────────────────────────────────────────────
|
|
323
351
|
# Ids/categories in refs that match no rule in the catalog. nil (no --select
|
|
324
352
|
# or --ignore given) passes through as no unknowns.
|
|
@@ -370,7 +398,9 @@ module Sloplint
|
|
|
370
398
|
# With the judge (sloplint-judge is installed here). It sends the text to api.typesafe.ai,
|
|
371
399
|
# so an agent asks the person before running it. Check first, ask, then run:
|
|
372
400
|
sloplint-judge status # exit 0 = a key is set up (reads no key), 2 = not
|
|
373
|
-
sloplint check --judge --markdown -o json FILE # output becomes {"notes": [...], "judge": {requests, tokens, cost_usd}}
|
|
401
|
+
sloplint check --judge --markdown -o json --register "READER" FILE # output becomes {"notes": [...], "judge": {requests, tokens, cost_usd}}
|
|
402
|
+
# READER names who the document is for. Without it the judge guesses the reader from the text;
|
|
403
|
+
# naming one keeps the judge from guessing.
|
|
374
404
|
# exit 3 = the model could not be reached; nothing was checked, not even the regex rules
|
|
375
405
|
RECIPE
|
|
376
406
|
end
|
|
@@ -395,8 +425,10 @@ module Sloplint
|
|
|
395
425
|
check scan paths (or stdin) for AI-slop tells and report notes [default]
|
|
396
426
|
a first argument that is not a command name is taken as a path
|
|
397
427
|
--judge adds sloplint-judge's model-backed rules when that gem is installed
|
|
398
|
-
rules list the rule catalog (add --json for the machine-readable form)
|
|
399
|
-
|
|
428
|
+
rules list the rule catalog (add --json for the machine-readable form);
|
|
429
|
+
`sloplint-judge rules` lists the --judge rules
|
|
430
|
+
explain ID print one rule's message, rationale, and a bad/ok example;
|
|
431
|
+
works for any rule id in a note, --judge rules included
|
|
400
432
|
version print the sloplint version
|
|
401
433
|
|
|
402
434
|
global options:
|
data/lib/sloplint/engine.rb
CHANGED
|
@@ -3,6 +3,14 @@
|
|
|
3
3
|
require_relative "rules"
|
|
4
4
|
|
|
5
5
|
module Sloplint
|
|
6
|
+
# The reader sloplint-judge asks its questions for when --register names
|
|
7
|
+
# none. It names no audience, so the model reads the audience off the
|
|
8
|
+
# document, the way calibration matches each register to its own reader;
|
|
9
|
+
# a named default reverses rules on documents written for someone else.
|
|
10
|
+
# It lives here, not in the judge, because `sloplint check --help` quotes
|
|
11
|
+
# it on a machine without the judge gem.
|
|
12
|
+
DEFAULT_REGISTER = "the reader this document is written for"
|
|
13
|
+
|
|
6
14
|
# One match = one Note. See docs/SPEC.md "Note".
|
|
7
15
|
Note = Data.define(
|
|
8
16
|
:path, :line, :column, :severity, :confidence, :rule, :category,
|
data/lib/sloplint/output.rb
CHANGED
|
@@ -11,7 +11,7 @@ module Sloplint
|
|
|
11
11
|
return "" if notes.empty?
|
|
12
12
|
|
|
13
13
|
notes.map do |n|
|
|
14
|
-
head = "#{n.path}:#{n.line}:#{n.column}: #{n.severity} #{n.rule} #{n.message}"
|
|
14
|
+
head = "#{n.path}:#{n.line}:#{n.column}: #{n.severity} (#{n.confidence} confidence) #{n.rule} #{n.message}"
|
|
15
15
|
excerpt = " excerpt: #{n.context}"
|
|
16
16
|
why = " why: #{n.rationale}"
|
|
17
17
|
fix = " fix: #{n.suggestion}"
|
data/lib/sloplint/rules.rb
CHANGED
|
@@ -84,6 +84,11 @@ module Sloplint
|
|
|
84
84
|
"and|but|or|nor|yet|too|enough|itself|indeed|which|that|here|there|so" \
|
|
85
85
|
"|time|world|numbers?|roots?|money|estate|user"
|
|
86
86
|
|
|
87
|
+
# The abstract heads the-x-is-the-x repeats across the copula: nouns that
|
|
88
|
+
# cannot name a specific object. the-x-is-not-the-x skips them, so the
|
|
89
|
+
# negated form of these heads is reported once, by the-x-is-the-x.
|
|
90
|
+
THE_X_HEADS = "reason|problem|question|lesson|difference|trick|move|goal|tell|pattern|insight|takeaway|shift|bet|catch|bottleneck|issue|game|cause|failure|magic|challenge|tension|irony|paradox|trap"
|
|
91
|
+
|
|
87
92
|
RULES = [
|
|
88
93
|
Rule.new(
|
|
89
94
|
id: "no-x-no-y",
|
|
@@ -759,7 +764,7 @@ module Sloplint
|
|
|
759
764
|
# negation ("isn't the reason") both count. The sentence-initial
|
|
760
765
|
# capital is not required, so "and the reason … is the reason …"
|
|
761
766
|
# flags too.
|
|
762
|
-
pattern: /\bthe\s+(
|
|
767
|
+
pattern: /\bthe\s+(#{THE_X_HEADS})\b
|
|
763
768
|
(?:[^.!?;:,\n]|\r?\n(?!\s*\n)){1,50}?\bis(?:n['’]t|\s+not)?\s+the\s+\1
|
|
764
769
|
(?=[,.;:!?]|\s+(?:of|for|with|in|on|to|at|behind|about|that|which|why|here|there|the|a|an|this|these|those|my|our|your|their|its|his|her|it|they|we|you|i|he|she|every|each|most|many|some|any|no|nobody|everyone|people|[a-z]+s)\b)/ix,
|
|
765
770
|
message: '"The X … is the X …" equates by repeating the head noun.',
|
|
@@ -797,6 +802,63 @@ module Sloplint
|
|
|
797
802
|
"two things while naming neither; it sounds like a diagnosis and " \
|
|
798
803
|
"delivers a tautology. Careful writers say what the second thing is."
|
|
799
804
|
),
|
|
805
|
+
Rule.new(
|
|
806
|
+
id: "the-x-is-not-the-x",
|
|
807
|
+
category: "cadence",
|
|
808
|
+
severity: "warning",
|
|
809
|
+
confidence: "medium",
|
|
810
|
+
# The negated twin of the-x-is-the-x with any head noun: "the job you
|
|
811
|
+
# applied for is not the job you will do", "the one you want is not the
|
|
812
|
+
# one that runs the studio". The same noun sits on both sides of a
|
|
813
|
+
# negated copula, caught by a backreference, and a clause on either side
|
|
814
|
+
# splits it into two things the sentence never tells apart. The
|
|
815
|
+
# repetition is the tell: a writer who knew what separates the two would
|
|
816
|
+
# name it instead of mirroring the noun.
|
|
817
|
+
#
|
|
818
|
+
# The narrowings are structural. The clause between the two heads is
|
|
819
|
+
# capped at fifty characters and may not hold sentence punctuation, a
|
|
820
|
+
# comma, semicolon or colon, so the heads share one clause. The second
|
|
821
|
+
# head may not be followed by "of", which names the difference ("the
|
|
822
|
+
# voice was not the voice of her brother"), and "not the same X" never
|
|
823
|
+
# matches because "same" sits between "the" and the head. "same" is not
|
|
824
|
+
# a head either. The-x-is-the-x's abstract heads are skipped, since that
|
|
825
|
+
# rule already reports their negated form.
|
|
826
|
+
pattern: /\bthe#{WRAP_GAP}+(?!(?:#{THE_X_HEADS}|same)\b)([[:alpha:]]{2,})\b
|
|
827
|
+
(?:[^.!?;:,\n]|\r?\n(?!\s*\n)){0,50}?
|
|
828
|
+
\b(?:is|are|was|were)(?:n['’]t|#{WRAP_GAP}+not)#{WRAP_GAP}+the#{WRAP_GAP}+\1\b
|
|
829
|
+
(?!['’-]|#{WRAP_GAP}+of\b)/ix,
|
|
830
|
+
message: '"The X … is not the X …" splits one noun in two and names neither half.',
|
|
831
|
+
suggestion: "Name the thing you mean; say what separates it from the other.",
|
|
832
|
+
examples_bad: [
|
|
833
|
+
"The job you applied for is not the job you will do.",
|
|
834
|
+
"Agencies staff two kinds of lead, and the one you need is not the one who pitches.",
|
|
835
|
+
"The customer who signs the contract isn't the customer who uses the product.",
|
|
836
|
+
"The meeting on the calendar was not the meeting that mattered.",
|
|
837
|
+
"The ones who ship first are not the ones who ship well.",
|
|
838
|
+
# A hard-wrapped clause survives one newline.
|
|
839
|
+
"The hire you need\nis not the hire the board will pitch."
|
|
840
|
+
],
|
|
841
|
+
examples_ok: [
|
|
842
|
+
# "of" after the second head names the difference.
|
|
843
|
+
"The voice behind the door was not the voice of her brother.",
|
|
844
|
+
# "the same X" is an identity statement.
|
|
845
|
+
"The captain on the bridge tonight isn't the same captain who hired me.",
|
|
846
|
+
"The same caution is not the same thing as fear.",
|
|
847
|
+
# Different heads.
|
|
848
|
+
"The bug in staging is not the cause of the outage.",
|
|
849
|
+
# A sentence boundary splits the clause.
|
|
850
|
+
"The plan was ready. It is not the plan we drew up.",
|
|
851
|
+
# A comma is a boundary: the heads must share a clause.
|
|
852
|
+
"The build passed, and it is not the build we ship.",
|
|
853
|
+
# A paragraph break ends the frame.
|
|
854
|
+
"The draft was long\n\nand it is not the draft we sent."
|
|
855
|
+
],
|
|
856
|
+
rationale: "Repeating one noun on both sides of 'is not' splits it into two things " \
|
|
857
|
+
"without saying what tells them apart. It sounds like a distinction and " \
|
|
858
|
+
"withholds it, so the reader has to read on to learn which one is meant. " \
|
|
859
|
+
"People write the shape when the noun really is one thing seen twice, " \
|
|
860
|
+
"which is why the confidence is medium."
|
|
861
|
+
),
|
|
800
862
|
Rule.new(
|
|
801
863
|
id: "same-determiner-chain",
|
|
802
864
|
category: "cadence",
|
|
@@ -3035,18 +3097,27 @@ module Sloplint
|
|
|
3035
3097
|
category: "cadence",
|
|
3036
3098
|
severity: "info",
|
|
3037
3099
|
confidence: "low",
|
|
3038
|
-
|
|
3100
|
+
# A comma before the first word, across any whitespace, means the tail
|
|
3101
|
+
# of a longer series; \K drops that prefix so the note points at the
|
|
3102
|
+
# first word, and the word-character guard keeps it from starting mid-word.
|
|
3103
|
+
pattern: /(?:\A|(?<=[^,\s]))\s*\K(?<![\w'-])[\w'-]+,\s+[\w'-]+,\s+(?:and\s+)?[\w'-]+[.!?]/,
|
|
3039
3104
|
message: "Three single words in a comma series closing a sentence (heuristic; high false-positive).",
|
|
3040
3105
|
suggestion: "Fine in moderation; watch for the AI habit of ending on triplets.",
|
|
3041
3106
|
examples_bad: ["It was fast, cheap, and simple."],
|
|
3042
3107
|
examples_ok: [
|
|
3043
3108
|
"We met on Tuesday afternoon.",
|
|
3044
3109
|
# Phrasal items do not match; the closing two slots take one word each.
|
|
3045
|
-
"It was very fast, very cheap, and very simple."
|
|
3110
|
+
"It was very fast, very cheap, and very simple.",
|
|
3111
|
+
# Four or more items are a list, not a triad.
|
|
3112
|
+
"Pack tents, stoves, rope, lamps, and maps.",
|
|
3113
|
+
"We grow beans, leeks, chard, kale.",
|
|
3114
|
+
"Pack tents, fly-sheets, pegs, and rope.",
|
|
3115
|
+
"Load the van with crates,\n ladders, buckets, and brooms."
|
|
3046
3116
|
],
|
|
3047
3117
|
rationale: "Rule-of-three endings are a model habit, but humans use them too — off by default. " \
|
|
3048
3118
|
"The closing two items must be single words; a triad of phrases is three ordinary " \
|
|
3049
|
-
"list items to a regex, so those are left unflagged rather than guessed at."
|
|
3119
|
+
"list items to a regex, so those are left unflagged rather than guessed at. The last " \
|
|
3120
|
+
"three words of a longer series are not a triad and are left alone too."
|
|
3050
3121
|
),
|
|
3051
3122
|
# clause-triad-then was cut. The pattern (comma-clause, comma-clause,
|
|
3052
3123
|
# "then" clause) had no way to require the clauses actually be parallel
|
|
@@ -3262,6 +3333,146 @@ module Sloplint
|
|
|
3262
3333
|
"why this is a question rather than a verdict; a draft full of them, " \
|
|
3263
3334
|
"especially as list items, should be read as a warning."
|
|
3264
3335
|
),
|
|
3336
|
+
Rule.new(
|
|
3337
|
+
id: "negative-subject",
|
|
3338
|
+
category: "cadence",
|
|
3339
|
+
severity: "warning",
|
|
3340
|
+
confidence: "medium",
|
|
3341
|
+
# A negative pronoun (Nothing, Nobody, No one, None) as the subject of
|
|
3342
|
+
# a clause, where the clause carries one of two structural markers
|
|
3343
|
+
# instead of stopping at the bare subject and verb. Bare "Nothing
|
|
3344
|
+
# happened." and "No one knows." are the common case in ordinary
|
|
3345
|
+
# prose and are not matched on their own; a regex cannot tell a
|
|
3346
|
+
# lexical verb from any other word, so the narrowing has to come from
|
|
3347
|
+
# what surrounds the subject, not from the verb.
|
|
3348
|
+
#
|
|
3349
|
+
# Two other frames were probed and cut, both for the same reason: a
|
|
3350
|
+
# regex can't tell a report of a real, specific absence from the
|
|
3351
|
+
# vague version the tell is aimed at.
|
|
3352
|
+
# - "No" plus a common noun carrying the role ("No vendor holds a
|
|
3353
|
+
# contract.") looked like the same kind of narrowing, but it is
|
|
3354
|
+
# indistinguishable from the ordinary way English negates a
|
|
3355
|
+
# subject -- "No installation is necessary.", "No magic is
|
|
3356
|
+
# involved.", "No exceptions are raised on HTTP timeouts." are
|
|
3357
|
+
# three hits from a 266k-word read of installed gems' READMEs,
|
|
3358
|
+
# all fair, none of them slop. On RAID it fired at or above the
|
|
3359
|
+
# model rate in every domain that had any hits at all.
|
|
3360
|
+
# - A bare pronoun with a prepositional phrase before the verb
|
|
3361
|
+
# ("Nobody on the on-call page owns the service.") looked
|
|
3362
|
+
# narrower, since the PP seemed to be dressing up a vague
|
|
3363
|
+
# subject. It isn't: "Nobody on the team has reviewed the
|
|
3364
|
+
# migration yet.", "Nothing in the runbook mentions this failure
|
|
3365
|
+
# mode." and "No one from the platform team joined the
|
|
3366
|
+
# postmortem." have the same shape and are exactly the opposite
|
|
3367
|
+
# of the tell -- a real, specific absence, with the PP naming the
|
|
3368
|
+
# scope. Nothing here can tell those apart from "Nobody on the
|
|
3369
|
+
# on-call page owns the service.", which has the identical shape.
|
|
3370
|
+
# Neither frame has a structural narrowing left to try that does not
|
|
3371
|
+
# become a word or exception list, so both ship cut rather than
|
|
3372
|
+
# forced; the maintainer's cases may need a judge rule instead.
|
|
3373
|
+
#
|
|
3374
|
+
# A clause starts at the beginning of the text, at a full stop,
|
|
3375
|
+
# question mark, exclamation mark, semicolon or colon, or at a line
|
|
3376
|
+
# break -- actually-not-x's clause-start set. Frame 1 below adds one
|
|
3377
|
+
# more: after a coordinating conjunction (and, but, or, so, yet, nor),
|
|
3378
|
+
# since that is how a second clause opens inside one sentence
|
|
3379
|
+
# ("...and nothing is queued"). Frame 2 stays on the plain
|
|
3380
|
+
# clause-start set: a conjunction most often joins two noun phrases
|
|
3381
|
+
# inside one clause, not two clauses ("two real roots and no real
|
|
3382
|
+
# numbers"), and nothing here can tell the two apart, so only the
|
|
3383
|
+
# frame that already requires its own internal "and" reaches past it.
|
|
3384
|
+
#
|
|
3385
|
+
# The two frames, each pinned by its own examples_ok:
|
|
3386
|
+
# 1. Two negative-subject clauses joined by "and" -- the pronoun
|
|
3387
|
+
# pairing repeats, which is the cadence itself ("Nothing is
|
|
3388
|
+
# deployed and nothing is queued.").
|
|
3389
|
+
# 2. A negative-subject clause whose object carries its own negated
|
|
3390
|
+
# relative clause ("nothing gets merged that does not get
|
|
3391
|
+
# reviewed."). The relative clause's negation is the full closed
|
|
3392
|
+
# set of auxiliaries and their contractions -- does/do/did,
|
|
3393
|
+
# has/have/had, is/are/was/were -- not just does/do/did. What
|
|
3394
|
+
# follows the negation runs one to ten words, atomic, not one to
|
|
3395
|
+
# three: "that does not pass the full suite first" needs the
|
|
3396
|
+
# room, and the boundary (a full stop or a clause break) is what
|
|
3397
|
+
# keeps the run from drifting into the next clause, not the cap.
|
|
3398
|
+
#
|
|
3399
|
+
# A clause the rule flags does not have to be the last clause in the
|
|
3400
|
+
# sentence: what ends it is a full stop, or a clause break (a comma
|
|
3401
|
+
# before a coordinating conjunction, or a bare comma, semicolon or
|
|
3402
|
+
# colon), so "Nothing merges here, and nothing that fails review
|
|
3403
|
+
# merges elsewhere either." is caught by the first clause rather than
|
|
3404
|
+
# missed for want of a period at the end of the sentence. Each
|
|
3405
|
+
# frame's word count between the subject and that boundary is capped
|
|
3406
|
+
# and atomic, so an unpunctuated run cannot backtrack
|
|
3407
|
+
# catastrophically. Frame 1's first predicate excludes "and" as one
|
|
3408
|
+
# of its words, and frame 2's first predicate excludes "that": each
|
|
3409
|
+
# is followed by that literal word, and an atomic group cannot
|
|
3410
|
+
# backtrack to give it back once it has swallowed it as an ordinary
|
|
3411
|
+
# word instead.
|
|
3412
|
+
#
|
|
3413
|
+
# A clause start may also carry one or two markdown emphasis markers
|
|
3414
|
+
# (*, _) directly before the subject, since a markdown document bolds
|
|
3415
|
+
# or italicizes the opening of a sentence or a list item structurally
|
|
3416
|
+
# ("*Nothing is posted and nothing is vacant..."), the same way a
|
|
3417
|
+
# sentence start already tolerates a list marker elsewhere in this
|
|
3418
|
+
# file. The marker is inside the match, same as the subject.
|
|
3419
|
+
pattern: /(?:\A|(?<=[.;:!?\n])[ \t]*|\b(?:and|but|or|so|yet|nor)\b[ \t]+)\K(?:[*_]{1,2})?
|
|
3420
|
+
(?:Nothing|Nobody|No[ \t]+one|None)(?>(?:[ \t]+(?!and\b)[\w'’-]+){1,8})[ \t]+and[ \t]+
|
|
3421
|
+
(?:Nothing|Nobody|No[ \t]+one|None)(?>(?:[ \t]+[\w'’-]+){1,8})
|
|
3422
|
+
(?:[.!?]|,[ \t]+(?:and|but|or|so|yet|nor)\b|[,;:])
|
|
3423
|
+
|
|
|
3424
|
+
(?:\A|(?<=[.;:!?\n])[ \t]*)\K(?:[*_]{1,2})?
|
|
3425
|
+
(?:Nothing|Nobody|No[ \t]+one|None)(?>(?:[ \t]+(?!that\b)[\w'’-]+){1,3})[ \t]+that[ \t]+
|
|
3426
|
+
(?:(?:does|do|did|has|have|had|is|are|was|were)[ \t]+not|(?:doesn|don|didn|hasn|haven|hadn|isn|aren|wasn|weren)['’]t)
|
|
3427
|
+
(?>(?:[ \t]+[\w'’-]+){1,10})
|
|
3428
|
+
(?:[.!?]|,[ \t]+(?:and|but|or|so|yet|nor)\b|[,;:])
|
|
3429
|
+
/ix,
|
|
3430
|
+
message: '"Nothing/Nobody/No one" as the subject names no one and nothing specific.',
|
|
3431
|
+
suggestion: "Name the specific thing that is missing, or who is responsible for it.",
|
|
3432
|
+
examples_bad: [
|
|
3433
|
+
"Nothing is deployed and nothing is queued.",
|
|
3434
|
+
"The queue is empty; nothing gets merged that does not get reviewed.",
|
|
3435
|
+
# A clause break, not the end of the sentence, closes each frame.
|
|
3436
|
+
"Nobody merges anything that hasn't passed review.",
|
|
3437
|
+
"Nothing is staged and nothing is promoted this week, and the freeze holds through Friday.",
|
|
3438
|
+
# A longer relative-clause tail than three words.
|
|
3439
|
+
"One rule holds every release: nothing ships that does not pass the full suite first.",
|
|
3440
|
+
# A markdown emphasis marker opening the sentence.
|
|
3441
|
+
"*Nothing is queued and nothing is running on the batch cluster tonight."
|
|
3442
|
+
],
|
|
3443
|
+
examples_ok: [
|
|
3444
|
+
# The bare pronoun and a plain verb, with neither of the two frames.
|
|
3445
|
+
"No one knows.",
|
|
3446
|
+
"Nothing happened.",
|
|
3447
|
+
"Nobody owns the service.",
|
|
3448
|
+
# A single clause, not a pairing: no second "and nothing ...".
|
|
3449
|
+
"Nothing is deployed.",
|
|
3450
|
+
# A conjunction clause start with a plain verb, no pairing.
|
|
3451
|
+
"But nobody replied.",
|
|
3452
|
+
# "and nothing" inside one clause, not a second full clause.
|
|
3453
|
+
"The team reviewed the proposal and nothing else mattered.",
|
|
3454
|
+
# "and" joining two noun phrases inside one clause ("has two real
|
|
3455
|
+
# roots and no real numbers"), not opening a second clause -- the
|
|
3456
|
+
# conjunction clause-start is frame 1's alone. Also pins the cut
|
|
3457
|
+
# "No" + noun frame: this stays quiet without it.
|
|
3458
|
+
"The equation has two real roots and no real numbers outside that range.",
|
|
3459
|
+
# The cut "No" + noun frame's own shape, left quiet on purpose: a
|
|
3460
|
+
# determiner and a common noun, not one of the pronouns.
|
|
3461
|
+
"No vendor holds a contract.",
|
|
3462
|
+
# The cut PP frame's shape: a real, specific absence, the
|
|
3463
|
+
# prepositional phrase naming the scope, not a vague subject
|
|
3464
|
+
# dressed up to look concrete. The maintainer's own examples.
|
|
3465
|
+
"Nobody on the team has reviewed the migration yet.",
|
|
3466
|
+
"Nothing in the runbook mentions this failure mode.",
|
|
3467
|
+
"No one from the platform team joined the postmortem."
|
|
3468
|
+
],
|
|
3469
|
+
rationale: "A negative pronoun standing as the subject names nothing specific, and a " \
|
|
3470
|
+
"sentence built to look concrete around it -- a paired clause, a negated " \
|
|
3471
|
+
"relative clause -- reads as if it said something when the subject still " \
|
|
3472
|
+
"names no one and nothing. Bare 'Nothing happened' is ordinary prose and is " \
|
|
3473
|
+
"left alone; the tell is the extra structure dressing up a subject that was " \
|
|
3474
|
+
"never named."
|
|
3475
|
+
),
|
|
3265
3476
|
Rule.new(
|
|
3266
3477
|
id: "quip-question",
|
|
3267
3478
|
category: "reader-address",
|
data/lib/sloplint/split.rb
CHANGED
|
@@ -81,14 +81,20 @@ module Sloplint
|
|
|
81
81
|
|
|
82
82
|
module_function
|
|
83
83
|
|
|
84
|
-
# text: the source.
|
|
85
|
-
#
|
|
86
|
-
#
|
|
87
|
-
#
|
|
88
|
-
#
|
|
89
|
-
#
|
|
84
|
+
# text: the source. Furniture lines -- headings (# and the setext title-
|
|
85
|
+
# plus-underline form), bullets, ordered items, table rows, block
|
|
86
|
+
# quotes, horizontal rules and reference lines -- are blanked whether or
|
|
87
|
+
# not markdown is set, because their shape alone says they are not
|
|
88
|
+
# prose. markdown additionally blanks fenced code, HTML
|
|
89
|
+
# comments and URLs before splitting, and the furniture that only shows
|
|
90
|
+
# once a document is read as Markdown: front matter, an indented code
|
|
91
|
+
# block, a lone HTML tag line, and a line that is only a code span or a
|
|
92
|
+
# URL. Splitting happens on the blanked copy; the texts returned are cut
|
|
93
|
+
# from the original at the same offsets, so an excerpt is always a
|
|
94
|
+
# string that is in the file. Blanking is character for character, so
|
|
95
|
+
# the offsets agree.
|
|
90
96
|
def paragraphs(text, markdown: false)
|
|
91
|
-
scan, asked = markdown ?
|
|
97
|
+
scan, asked = blank_furniture(markdown ? blank(text) : text, markdown ? blank_blocks(text) : text, markdown:)
|
|
92
98
|
# A document with nothing to leave out is asked about as written, and
|
|
93
99
|
# then every text is cut once instead of twice.
|
|
94
100
|
asked = text if asked == text
|
|
@@ -222,6 +228,13 @@ module Sloplint
|
|
|
222
228
|
# opens, and only on the first line of the file.
|
|
223
229
|
FRONT = /\A---[ \t]*\z/
|
|
224
230
|
|
|
231
|
+
# The underline of a setext heading: one or more "=" for a level-1
|
|
232
|
+
# title, two or more "-" for a level-2 one. FURNITURE only knows the
|
|
233
|
+
# "#" (ATX) form. A "---" underline already drops as a horizontal
|
|
234
|
+
# rule, but the title line above it used to stay prose, and a title
|
|
235
|
+
# underlined in "=" did not drop at all.
|
|
236
|
+
SETEXT_UNDERLINE = /\A(?:=+|-{2,})[ \t]*\z/
|
|
237
|
+
|
|
225
238
|
# How many lines of YAML front matter the document opens with: the ---
|
|
226
239
|
# on the first line, everything to the next --- line, and that line. Zero
|
|
227
240
|
# for a document that does not open with one, so a --- between two
|
|
@@ -241,10 +254,23 @@ module Sloplint
|
|
|
241
254
|
# original and in both copies. The line ending is taken off before the
|
|
242
255
|
# tests run: on a CRLF file it would otherwise sit between the line and
|
|
243
256
|
# the \z that a horizontal rule or a bare link ends at, and neither would
|
|
244
|
-
# be recognised as furniture.
|
|
245
|
-
|
|
257
|
+
# be recognised as furniture. markdown: false still drops a line whose
|
|
258
|
+
# shape alone marks it as furniture -- FURNITURE, a list item, a list
|
|
259
|
+
# item's continuation, a setext title and its underline -- but leaves
|
|
260
|
+
# front matter, an indented code block, a lone HTML tag line and a lone
|
|
261
|
+
# code-span-or-URL line alone, because those only read as furniture
|
|
262
|
+
# once the document is read as Markdown.
|
|
263
|
+
def blank_furniture(scan, shown, markdown: true)
|
|
246
264
|
lines = scan.each_line.to_a
|
|
247
|
-
front = front_matter(lines)
|
|
265
|
+
front = markdown ? front_matter(lines) : 0
|
|
266
|
+
# A setext title and its underline, marked ahead of the walk below so
|
|
267
|
+
# each line of the pair can see the other: the walk itself only ever
|
|
268
|
+
# looks at the line it is on. A line is a title when it is not blank
|
|
269
|
+
# and the next line is nothing but a setext underline; the underline
|
|
270
|
+
# is furniture only where a title sits right above it, so a "---"
|
|
271
|
+
# after a blank line is still read as the horizontal rule it is.
|
|
272
|
+
underline = lines.map { |l| l.chomp.match?(SETEXT_UNDERLINE) }
|
|
273
|
+
title = lines.each_index.map { |i| !lines[i].chomp.strip.empty? && underline[i + 1] == true }
|
|
248
274
|
item = false
|
|
249
275
|
prose = false
|
|
250
276
|
code = false
|
|
@@ -263,9 +289,10 @@ module Sloplint
|
|
|
263
289
|
# already -- the first line, or after a blank or furniture line --
|
|
264
290
|
# and where the indent is not a list item's second line. It then runs
|
|
265
291
|
# for as long as the indent holds.
|
|
266
|
-
code = (code && (indented || blank)) || (indented && !item && opens)
|
|
267
|
-
|
|
268
|
-
|
|
292
|
+
code = markdown && ((code && (indented || blank)) || (indented && !item && opens))
|
|
293
|
+
setext = title[i] || (i.positive? && title[i - 1])
|
|
294
|
+
dropped = i < front || code || listed || l.match?(FURNITURE) || (markdown && l.match?(HTML_LINE)) ||
|
|
295
|
+
(markdown && lone_inline?(l, prose)) || (item && l.match?(CONTINUED)) || setext
|
|
269
296
|
item = listed || (item && l.match?(CONTINUED))
|
|
270
297
|
prose = !dropped && !blank
|
|
271
298
|
opens = blank || dropped
|
data/lib/sloplint/version.rb
CHANGED