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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 9031ddb2a2a80637f1958c3ec24014261a23b017c25cc3032e72e85f9acf0313
4
- data.tar.gz: 53389164a5fdf74367d985bc1d3eff0d661c47cb70d22469ab89e55f56e4ac22
3
+ metadata.gz: 104eaad3b0c8de01284f36733d607140b606feb03b9f216225e62f2a25ade435
4
+ data.tar.gz: b6d8cf5cf2eb4bcaa01b99e2889f4aaa5f0fbbbce6c5663cdaf79bff45dcbc76
5
5
  SHA512:
6
- metadata.gz: 2029db5c55bdaed221ff62c629eaf9989bf05044274f340295ecb5122ba5cd063c773566c9c9bf8e201050348284103f6910aed13b6b24745b538ed030738612
7
- data.tar.gz: 46130960f17c5fd70b7a8752065fc85520b8eeac94498588b3d0b798daa3bdb8683ab5084e258e561a9b502227793d9746c0fc7e2d7ab2f143a0b5f7364c2fc7
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 linting notes. Think `proselint`, but aimed narrowly at the rhetorical 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 human text a person can read.
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. Humans are the secondary reader, and everything is built to keep the false-positive rate low enough that a flag is worth trusting.
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
- For the tells a regex cannot see, there is a second gem, [sloplint-judge](#sloplint-judge), whose rules are questions put to a model and whose notes come back in the same JSON. It is optional, it needs an API key, and it is the only part of sloplint that sends your text anywhere.
7
+ ## Quick start
8
8
 
9
- ## What it catches, and what it doesn't
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
- 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: those belong in `proselint` or `write-good`, not here. sloplint is not a general prose linter and never tries to be. It hunts the specific fingerprints of a language model, so an agent can act on a flag instead of second-guessing it.
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. The runtime is standard library only (`optparse`, `json`, native regex). The Ruby that macOS ships at `/usr/bin/ruby` is 2.6 and too old; `brew install ruby` gets you a current one.
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
- Not the web address of the page you are reading — just those two words with the slash, and no slash on the end. `https://github.com/benjaminjackson/sloplint` works too, but a link copied from a page you were browsing usually has more on the end of it and will fail. Then install **sloplint** from the list.
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
- Installing this way needs no package install of any kind, which is why it works in Cowork, where `gem install` cannot reach the internet. Claude Code carries the source along with the plugin and runs it in place.
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. Nothing updates on its own.
61
+ New rules reach you when you click **Update** on the marketplace; nothing updates on its own.
30
62
 
31
- Once it is installed, ask Claude to check a draft, or run `/sloplint:check`.
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, that puts a `sloplint` executable on your path.
88
+ Either way you get a `sloplint` executable on your path.
56
89
 
57
- ## Quick start
58
-
59
- The recipe sloplint is built around, and the one an agent should use:
90
+ ### In a Ruby project
60
91
 
61
- ```bash
62
- cat draft.md | sloplint check --markdown -o json -
92
+ ```ruby
93
+ gem "sloplint"
94
+ gem "sloplint-judge" # optional, the judge rules
63
95
  ```
64
96
 
65
- `--markdown` blanks out code, HTML comments, and URLs first, `-o json` emits the machine-readable form, and `-` reads stdin. 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.
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
- The human-readable form drops `-o json`:
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
- -:1:34: error puffery-words Wikipedia-style puffery word/phrase — a common AI tell.
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
- -:2:1: info exact-exactly "exact/exactly" is reflexive emphasis unless it names something checkable.
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
- 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.
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 files as arguments, or `-` (or nothing) to read stdin, and these options:
124
+ `check` takes paths; `-`, or nothing, reads stdin. The options:
102
125
 
103
- - `--markdown` skips fenced code, inline code, HTML comments, and URLs before scanning. Off by default so it never silently eats prose.
104
- - `--select IDS` runs only these rules. Accepts comma-separated rule ids or category names.
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 five that are off by default. `--ignore` still applies on top.
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
- `explain` is the command an agent calls to decide whether a flag is worth acting on:
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
- ### `--judge`
147
+ ## The judge: the tells a regex cannot reach
124
148
 
125
- `sloplint check --judge` adds the rules of [sloplint-judge](#sloplint-judge) to the run, questions put to a model rather than regexes, and merges the notes into the same array in document order. It needs the sloplint-judge gem and an API key; the section below covers both.
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
- ## The note
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
- One match is one note. JSON output is an array of these, or an object keyed by path when more than one file is scanned. Under `--judge` that array or object sits under a `notes` key next to a `judge` key with the backend name, request count and token counts (see [sloplint-judge](#sloplint-judge)). The schema is the contract:
153
+ ### Install it and get a key
130
154
 
131
- ```json
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
- An unknown id or category in `--select`/`--ignore` is a usage error (exit 2, naming the id) rather than a silent no-op, so a typo can't masquerade as a clean scan. Input that is empty or only whitespace is exit 2 for the same reason: a pipe that delivered nothing must not read as a clean draft. Only when every source is empty — one empty file among several named ones is taken as deliberate.
157
+ Put the key in one of two places. The keychain, which `key set` writes and the judge reads by default:
163
158
 
164
- ## sloplint-judge
165
-
166
- A second gem in this repository, for the tells a regex cannot see. Its rules are questions put to a System One model (Jev, from TypeSafe) about one paragraph or one sentence at a time: does this paragraph end on a summary, a moral or a hope, does this sentence tell the stated reader anything they did not know, does it name anything a reader could check. The answers come back as sloplint notes, same fields, same JSON, same exit codes, so anything that already reads sloplint's output reads the judge's without change.
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
- ### Install
163
+ Or the environment, which beats the keychain:
171
164
 
172
165
  ```bash
173
- gem install sloplint sloplint-judge
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
- `key set` hands your terminal to the keychain tool (`security` on macOS, `secret-tool` from libsecret on Linux), which asks for the key with echo off, so the key is never on a command line, in shell history or in a dotfile. Setting `TYPESAFE_API_KEY` in the environment works too and takes precedence. One thing the keychain does not do: it keeps the key out of the agent's environment, not out of your account, since any process running as you can read the item back. `sloplint-judge status` says whether a key was found and where, without printing it, and `sloplint-judge key unset` removes the item.
169
+ The keychain is for a workstation. On a server, use the environment, or whatever secret manager fills the environment.
178
170
 
179
- Requires Ruby 3.3+ and sloplint 0.9 or later. The judge is not a plugin of its own: the Claude Code plugin at the root of this repository already carries it, and the `/sloplint:check` skill asks before it runs the judge. A key in the environment makes the judge possible; it does not make it run. The skill puts the question once per conversation, says what leaves the machine and what it costs, and stays offline unless the answer is yes or the request already asked for the judge.
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
- ### Run
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
- The one command to know, and the one an agent should use:
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 draft.md
180
+ cat draft.md | sloplint check --judge --markdown -o json -
187
181
  ```
188
182
 
189
- That runs both catalogs and merges the notes in document order. Because the judge spent money, the JSON says how much: the notes sit under `notes` and a `judge` object carries the backend, the number of requests, the token counts the backend reported and the cost in dollars. The same figures go to stderr in one line for the human formats.
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
- Without the sloplint-judge gem it exits 2 and says to install it. Without a key it exits 2 and says which variable to set. If the model cannot be reached, or answers in a shape the judge does not understand, it exits 3 and writes no notes at all, the regex ones included, so a partial run can never pass as a clean one.
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 gem also puts a `sloplint-judge` executable on your path for the judge on its own:
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
- `check` takes `--markdown`, `--select`, `--ignore` and `--strict` with the same meanings as sloplint's. `--strict` runs the three rules that are off by default, runs the sentence rules on every sentence, rather than only in the paragraphs a paragraph rule flagged or skipped as too short, and keeps the notes the model was not confident about. `--register TEXT` says who the reader is; the default is an engineer on the team reading a design document, and every question is asked on that reader's behalf, so a rule such as `no-news` flags a sentence that reader already knows rather than one anybody would.
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
- Fourteen rules in two categories. `sloplint-judge rules` lists them and `sloplint-judge explain ID` prints the question the model is asked, the answer that flags, and the fixtures. The bar is a little different from the regex catalog's: a judge rule ships when a reader shown the flagged unit agrees it should go, whoever wrote it, and how sharply it separates model prose from human prose sets its severity. So `throat-clearing` is `info`, not gone: human abstracts open by announcing the paper, and it is dead weight either way.
221
+ ## The note
220
222
 
221
- - **paragraph** (6): `particulars`, a paragraph that names nothing a reader could check; `wrap-up`, a paragraph that ends on a summary, a moral or a hope; `throat-clearing`, a paragraph that opens by announcing its topic; `self-narration`, a paragraph that signposts the document instead of saying something; `promotional`, a paragraph that praises its subject and measures nothing; `same-weight`, a paragraph that states its guesses and opinions as flatly as its measurements. `same-weight` is off by default; name it in `--select` or pass `--strict`.
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
- Each note's `confidence` is the lower of the rule's own ceiling and how sure the model was of that answer. A note the model was unsure about is dropped unless you pass `--strict`, the same way sloplint drops its low-confidence rules.
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
- ### Cost and configuration
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
- One request per paragraph carries the paragraph questions, and one request per examined sentence carries the sentence questions, about eight in parallel. A 2,000-word document runs in a few seconds. Every run reports requests, tokens and cost, in the JSON under `judge` and on stderr. Jev returns token counts and no price, so the dollar figure is computed from TypeSafe's public price: $42 per billion input tokens, and output tokens are free. At that rate a 2,000-word document costs well under a cent.
245
+ ## Exit codes
229
246
 
230
- Configuration is from the environment, plus the OS keychain for the key:
247
+ The exit code is the contract. A crash exits nonzero outside these four.
231
248
 
232
- | variable | default | meaning |
233
- |---|---|---|
234
- | `TYPESAFE_API_KEY` | none, required | bearer key sent with every request; from the environment, else the keychain item `key set` wrote |
235
- | `SYSTEMONE_MODEL` | `jev-latest` | model name |
236
- | `SYSTEMONE_URL` | `https://api.typesafe.ai/v1/systemone` | endpoint; must be `https` on a `typesafe.ai` host |
237
- | `SLOPLINT_JUDGE_BACKEND` | `jev` | which adapter to use |
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
- The design, the calibration that decides which rules ship, and how to add a backend are in [docs/JUDGE.md](docs/JUDGE.md).
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
- 82 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.
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` ("Here's what nobody tells you", "the part most people get wrong").
247
- - **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.").
248
- - **cadence** (18) 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"), `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).
249
- - **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).
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` ("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`.
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` ("some critics argue", "it is widely regarded"), `if-im-being-honest` (the candor preamble, from slopwash.com's "false intimacy"), `and-thats-fine`.
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 "No invite?", at `medium` confidence).
253
- - **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).
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, and they answer different questions. **Severity** is what the construct 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 mostly harmless and worth knowing (`em-dash`). **Confidence** is how likely the match is a false positive: `high` when almost every hit is the real tell, `medium` when ordinary prose produces the same shape often enough that an agent should read the rationale first, `low` when the pattern cannot tell the tell from the ordinary use at all.
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 two used to be one word, so a cheap tell we were sure about and an expensive one we were guessing at both came out as `warning`. They no longer do. `error` is in use: the puffery family, the tautology closers, and the self-ranking superlatives all cost the sentence something wherever they appear, however sure or unsure we are of the match.
290
+ ### The judge rules
259
291
 
260
- 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, 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 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. `and-nothing-else` and `nothing-else-frag` are a pair of the same shape and both sit at `high`, because the fragment half carries a capital letter and a whole-sentence requirement that the comma half has no equivalent of, so it is the narrower of the two rather than the quieter one.
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
- The five `low` rules are the ones that run **off by default**. They run when you name them — `sloplint check --select rule-of-three -` — or when you pass `--strict`, which runs the whole catalog when you give it no `--select`. `rule-of-three` flags three single words in a comma series closing a sentence, which humans do all the time; the closing two items must be single words, so a triad of phrases does not match, because a regex cannot tell one from an ordinary list. `genuinely` flags every occurrence of the word; as an intensifier it rates the writer's sincerity, but it still does real work when it draws a contrast, and nothing in the sentence separates the two. `epistrophe` flags two clauses ending on the same phrase, a named figure that careful writers use on purpose and that, on Hacker News, is mostly plain phrase reuse. `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 flags the same way. `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. Naming a category in `--select` only turns on that category's non-low rules; naming the rule's own 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 members. `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.
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
- ### Markdown handling
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
- `--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 whole file as prose and will flag text inside your code fences. Pass `--markdown` whenever the input is Markdown.
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 `Data.define` object in `lib/sloplint/rules.rb` with a regex, a message, a suggestion, and one bad and one ok fixture:
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` 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.
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` 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.
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
- No `Gemfile` -- install `rspec` and `rake` yourself (`gem install rspec rake`), then:
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
- `rules_spec.rb` checks every rule against its fixtures. `cli_spec.rb` covers exit codes, stdin, JSON schema, `--select` and `--ignore`, and that `--markdown` skips code. A slop fixture in `spec/fixtures/` doubles as an integration check.
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 full design, including why this is a fresh tool rather than a proselint extension.
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.- `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.
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.") { strict = true }
72
- o.on("--judge", "Also run sloplint-judge's rules, which ask a model (needs the gem and a key).") { judge = true }
73
- o.on("--register TEXT", "With --judge: who the reader is.") { |v| register = v }
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 || Judge::Engine::DEFAULT_REGISTER, strict:)
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
- explain ID print one rule's message, rationale, and a bad/ok example
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:
@@ -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,
@@ -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}"
@@ -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+(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)\b
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
- pattern: /\b[\w'-]+,\s+[\w'-]+,\s+(?:and\s+)?[\w'-]+[.!?]/,
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",
@@ -81,14 +81,20 @@ module Sloplint
81
81
 
82
82
  module_function
83
83
 
84
- # text: the source. markdown: blank code, HTML comments and URLs before
85
- # splitting, and blank furniture lines so they neither count as prose nor
86
- # take the prose around them with them. Splitting happens on the blanked
87
- # copy; the texts returned are cut from the original at the same offsets,
88
- # so an excerpt is always a string that is in the file. Blanking is
89
- # character for character, so the offsets agree.
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 ? blank_furniture(blank(text), blank_blocks(text)) : [text, text]
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
- def blank_furniture(scan, shown)
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
- dropped = i < front || code || listed || l.match?(FURNITURE) || l.match?(HTML_LINE) ||
268
- lone_inline?(l, prose) || (item && l.match?(CONTINUED))
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sloplint
4
- VERSION = "0.9.0"
4
+ VERSION = "0.10.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: sloplint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Jackson