sloplint 0.6.0 → 0.7.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: d0d26e3bebafd7184f09aa41f70d1013e8ca6d9f460d4e0229815c18db08263a
4
- data.tar.gz: 944923cfab5a213d08a5367bae1dfd75fb4fc71aa9a004fbb59806b5dace1376
3
+ metadata.gz: 33eff57c67530d0745b1ba5009a97128929a73ec27ae09afaba8d9057f0f997b
4
+ data.tar.gz: 3b76104840a3c2ab471651cbdf18dd4a9027707aaf100bc901430c7f9e121216
5
5
  SHA512:
6
- metadata.gz: 19ad341fcb7a415b737bc4e1fdd3f355d4b6bd91429209477b4ac4605a2f18b7372bf1791e34e9e0e69b6d50b17a02abf959981b7bd32b5345a30ebc3151cb87
7
- data.tar.gz: 9f0732fe535cc7753ed2b8380687575c4b109b5f9be31409e1d207c9ccb3b7ffbcb69a833427e68bb73c905369b47dd920ce7dd7162e456a61dd7a07c468c853
6
+ metadata.gz: 4d1a47eae97395fa37dd1fa50b9a4894ccae70064d15542abdce0197cf715956d23c6ba75bee7bb4f56bc6ada8986485aff5bb1e06c9ecbb1ac6b763705588e1
7
+ data.tar.gz: cb1ba2ed058855336b71bdc6e10a743e021a5d5d85e0207b8e0564b0fcd9076c9c45eb021eee6fc15a4eac92709b3440ebb6e1487da3c8981853ff37ebd01419
data/CHANGELOG.md CHANGED
@@ -3,6 +3,72 @@
3
3
  All notable changes to this project are documented here. Format loosely
4
4
  follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/).
5
5
 
6
+ ## [0.7.0] - 2026-09-15
7
+
8
+ ### Added
9
+
10
+ - `--strict` runs the full catalog, including the five rules that ship off
11
+ by default. Before this, the only way to turn one on was to name it in
12
+ `--select`, which also turned off every other rule unless you listed
13
+ those too. `--ignore` still subtracts on top of `--strict`, and
14
+ `--select` is unchanged.
15
+ - `ellipsis-closer` (structure, info) flags the long-sentence-then-short-closer
16
+ kicker `mic-drop-closer` catches, but built on the opposite verb shape: the
17
+ closer's verb phrase is elided down to a bare auxiliary with no object,
18
+ "The agent did." rather than a quantifier plus a full verb. No subject list
19
+ is needed — the tell is the missing verb, not the subject — so "That",
20
+ "This" and "It" are not excluded the way `mic-drop-closer` excludes them.
21
+ A negative lookahead drops a closer that still holds a wh-word, since that
22
+ marks a subordinate clause with its own complement rather than an elided
23
+ one. Reuses `mic-drop-closer`'s long-sentence prefix.
24
+ - `actually-not-x`: the adverb and a trailing "…, not X" in one clause, as in
25
+ "The disclosure actually covered two incidents, not one". Either marker
26
+ alone is ordinary technical English and neither is matched on its own; the
27
+ tell is the two of them doubled up, correcting an alternative the reader was
28
+ never offered. The narrowing is structural, not a word list: the comma in
29
+ front of "not" has to be the first comma of its clause and has to follow a
30
+ word, so a fronted setup ("Despite the name, …") and a parenthetical both
31
+ drop it. Nothing crosses a line break, so two rows of a table cannot weld
32
+ into one correction.
33
+ - `real-x-real-y` (rhetorical-tic, info) flags the same "real" used twice
34
+ attributively in one sentence, in front of two different nouns: "it can
35
+ trigger real API calls or hold real credentials". Bare "real" is an
36
+ ordinary word, so the narrowing is the repetition itself, not a noun
37
+ list. A hyphen on either side takes it out ("real-time", "non-real"), a
38
+ backreference requires the second noun to differ from the first, and a
39
+ short closed list drops the fixed senses of this register (real time,
40
+ real-world, real numbers, real user monitoring, real estate, real money).
41
+ - `phrase-echo` (rhetorical-tic, info, off by default) flags a three-word
42
+ phrase that comes back within a few hundred words, the model reusing a
43
+ phrase it has just minted. Each word must be four characters or more and
44
+ lowercase-led, and one of them six letters with nothing but letters, so "in
45
+ order to", "couldn't have been" and "New York City" never match. The window
46
+ does not cross into a list item or a table row. Off by default: a term of
47
+ art repeats because it must, and in reference prose the pattern fires on
48
+ names by the thousand per million words.
49
+ - `trailing-restatement` (structure, info, off by default) flags the tail
50
+ that says the sentence again in other words: ", which means …", ", which
51
+ is to say …", ", meaning …", and four participles that hang a result off
52
+ the sentence (", making it easier", ", allowing us to", ", giving them
53
+ more", ", leaving you with"). The connective is all a pattern can see, and the
54
+ same connective states a real consequence in careful prose, so the rule
55
+ runs only when selected.
56
+
57
+ ### Fixed
58
+
59
+ - `check`'s options now work without the command word: `sloplint --markdown -`
60
+ and `sloplint --strict -` scan, as the README has said `sloplint -` does.
61
+ Before, the global parser rejected any option it did not own with
62
+ `invalid option`, so `check` had to be spelled out whenever an option
63
+ came first. An option no command knows still exits 2.
64
+ - `--markdown` now blanks `<!-- -->` comment blocks along with fenced code,
65
+ inline code and URLs. Planning notes kept in comments never render, so
66
+ every rule that fired inside one was a permanent false positive.
67
+ - `rule-of-three`'s message, rationale, README and SPEC entries said it
68
+ flagged three parallel comma items. The pattern requires single words in
69
+ the closing two slots, so a triad of phrases never matched; the text now
70
+ says that.
71
+
6
72
  ## [0.6.0] - 2026-09-08
7
73
 
8
74
  The catalog grows from 50 rules to 75, and sloplint ships as a Claude Code
data/README.md CHANGED
@@ -60,7 +60,7 @@ The recipe sloplint is built around, and the one an agent should use:
60
60
  cat draft.md | sloplint check --markdown -o json -
61
61
  ```
62
62
 
63
- `--markdown` blanks out code 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.
63
+ `--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.
64
64
 
65
65
  The human-readable form drops `-o json`:
66
66
 
@@ -98,9 +98,10 @@ version print the sloplint version
98
98
 
99
99
  `check` takes files as arguments, or `-` (or nothing) to read stdin, and these options:
100
100
 
101
- - `--markdown` skips fenced code, inline code, and URLs before scanning. Off by default so it never silently eats prose.
101
+ - `--markdown` skips fenced code, inline code, HTML comments, and URLs before scanning. Off by default so it never silently eats prose.
102
102
  - `--select IDS` runs only these rules. Accepts comma-separated rule ids or category names.
103
103
  - `--ignore IDS` skips these rules. Same id-or-category form.
104
+ - `--strict` runs every rule, including the five that are off by default. `--ignore` still applies on top.
104
105
 
105
106
  `explain` is the command an agent calls to decide whether a flag is worth acting on:
106
107
 
@@ -154,11 +155,11 @@ An unknown id or category in `--select`/`--ignore` is a usage error (exit 2, nam
154
155
 
155
156
  ## The rule catalog
156
157
 
157
- 75 rules across four categories. `sloplint rules` prints them; `sloplint rules --json` gives an agent the enumerable form.
158
+ 80 rules across four categories. `sloplint rules` prints them; `sloplint rules --json` gives an agent the enumerable form.
158
159
 
159
- - **rhetorical-tic** (53) the cadence patterns: `no-x-no-y`, `no-x-no-y-frag`, `from-x-to-y-chain` ("from guessing to measuring, from hoping to knowing"), `one-x-one-y` ("one reviewer, one queue, one deadline"), `same-determiner-chain` (any other repeated determiner, at `info`), `and-what-it-should` (the elliptical tail: "…, and what it should."), `abstract-lives-in` ("the value sits in the follow-up", at `info`), `the-x-is-the-x` ("the problem with A is the problem with B"), `bare-equative` ("The lesson is the handoff.", at `info`), `thats-the-whole`, `is-the-whole-x` (the same closer on any subject: "Consistency is the real test.", at `info`), `thats-how-x`, `announced-takeaway`, `exact-exactly`, `load-bearing`, `intersection-of`, `you-already-know`, `sit-with-that`, `hold-onto-that`, `cleanly`, `clean-count`, `cleanest-x`, `clean-x`, `not-nothing`, `is-is` (doubled copula), `if-im-being-honest` (the candor preamble, from slopwash.com's "false intimacy"), `and-nothing-else` (the trailing "…, and nothing else"), `honestly`, `honest-x`, `most-honest-x` (the honesty family, built the same way as the four `clean` rules above), `impact-verb` ("the outage impacted four thousand accounts"), `impact-noun-bare` ("the impact of X", at `info` because research prose uses it straight), and more.
160
+ - **rhetorical-tic** (56) the cadence patterns: `no-x-no-y`, `no-x-no-y-frag`, `from-x-to-y-chain` ("from guessing to measuring, from hoping to knowing"), `one-x-one-y` ("one reviewer, one queue, one deadline"), `same-determiner-chain` (any other repeated determiner, at `info`), `and-what-it-should` (the elliptical tail: "…, and what it should."), `abstract-lives-in` ("the value sits in the follow-up", at `info`), `the-x-is-the-x` ("the problem with A is the problem with B"), `bare-equative` ("The lesson is the handoff.", at `info`), `thats-the-whole`, `is-the-whole-x` (the same closer on any subject: "Consistency is the real test.", at `info`), `thats-how-x`, `announced-takeaway`, `exact-exactly`, `load-bearing`, `intersection-of`, `you-already-know`, `sit-with-that`, `hold-onto-that`, `cleanly`, `clean-count`, `cleanest-x`, `clean-x`, `not-nothing`, `is-is` (doubled copula), `if-im-being-honest` (the candor preamble, from slopwash.com's "false intimacy"), `and-nothing-else` (the trailing "…, and nothing else"), `honestly`, `honest-x`, `most-honest-x` (the honesty family, built the same way as the four `clean` rules above), `impact-verb` ("the outage impacted four thousand accounts"), `impact-noun-bare` ("the impact of X", at `info` because research prose uses it straight), and more.
160
161
  - **puffery** (6) five from Wikipedia's "signs of AI writing" — `puffery-words` (vibrant, nestled, groundbreaking, in the heart of), `rich-tapestry`, `vital-role`, `stands-serves-as`, `underscores-highlights` — plus `impact-noun-vague` ("a significant impact", "make an impact").
161
- - **structure** (15) `everyone-nobody` (the comma-spliced antithesis: "Everyone wants the dashboard, nobody maintains it."), `np-fragment-and` (the verbless "A named owner and a quarterly review.", at `info`), `quip-question` (the verbless "No invite?", at `info`), `mic-drop-closer` (the short quantifier-led sentence that ends a paragraph after a long one, at `info`; a draft full of them is a warning), `short-run` (three sentences of thirty characters or fewer in a row, at `info`, same caveat), `not-just-x-but-y`, `not-x-but-y` (the bare corrective), `isnt-x-its-y` (the same corrective split across two clauses: "It isn't the tool. It's the habit."), `not-by-x-but-by-y` (the corrective on a repeated preposition: "not by luck, but by design"), `em-dash` (any em dash), `em-dash-overuse` (three or more in one paragraph), `question-isnt` (the corrective frame in interrogative dress), `less-about-more-about`, `trailing-significance-participle` (the "…, showcasing its importance" clause), and `rule-of-three`.
162
+ - **structure** (17) `everyone-nobody` (the comma-spliced antithesis: "Everyone wants the dashboard, nobody maintains it."), `np-fragment-and` (the verbless "A named owner and a quarterly review.", at `info`), `quip-question` (the verbless "No invite?", at `info`), `mic-drop-closer` (the short quantifier-led sentence that ends a paragraph after a long one, at `info`; a draft full of them is a warning), `ellipsis-closer` (the same shape, but the closer's verb is elided down to a bare auxiliary: "The agent did.", at `info`), `short-run` (three sentences of thirty characters or fewer in a row, at `info`, same caveat), `not-just-x-but-y`, `not-x-but-y` (the bare corrective), `isnt-x-its-y` (the same corrective split across two clauses: "It isn't the tool. It's the habit."), `not-by-x-but-by-y` (the corrective on a repeated preposition: "not by luck, but by design"), `em-dash` (any em dash), `em-dash-overuse` (three or more in one paragraph), `question-isnt` (the corrective frame in interrogative dress), `less-about-more-about`, `trailing-significance-participle` (the "…, showcasing its importance" clause), `trailing-restatement` (the "…, which means …" tail that says the sentence again, off by default), and `rule-of-three`.
162
163
  - **hedging** (1) `vague-attribution`: "some critics argue," "it is widely regarded."
163
164
 
164
165
  Severity is `warning` for strong tells, `info` for weak or contextual ones. No rule currently ships at `error`; the tier is reserved for a pattern with essentially zero false-positive risk, and none has earned that yet.
@@ -167,11 +168,11 @@ Some tells come in a confident form and an ambiguous one, and those ship as a pa
167
168
 
168
169
  `and-nothing-else` and `nothing-else-frag` are a pair of the same shape, but both ship at `warning`. That is a deliberate exception: the fragment half carries a capital letter and a whole-sentence requirement that the comma half has no equivalent of, so it is the *narrower* of the two rather than the quieter one.
169
170
 
170
- Three rules ship **off by default**, and they run only when you name them: `sloplint check --select rule-of-three -`. `rule-of-three` flags three parallel comma items closing a sentence, which humans do all the time. `genuinely` flags every occurrence of the word; as an intensifier it rates the writer's sincerity, but it still does real work when it draws a contrast, and nothing in the sentence separates the two. `epistrophe` flags two clauses ending on the same phrase, a named figure that careful writers use on purpose and that, on Hacker News, is mostly plain phrase reuse.
171
+ Five rules ship **off by default**. They run when you name them `sloplint check --select rule-of-three -` — or when you pass `--strict`, which turns the whole catalog on. `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.
171
172
 
172
173
  ### Markdown handling
173
174
 
174
- `--markdown` replaces fenced code, inline code, 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.
175
+ `--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.
175
176
 
176
177
  ## Adding a rule
177
178
 
data/docs/SPEC.md CHANGED
@@ -138,9 +138,10 @@ global options:
138
138
 
139
139
  check options:
140
140
  paths ... files to scan; "-" or no paths reads stdin
141
- --markdown skip fenced/inline code spans
141
+ --markdown skip fenced/inline code spans, HTML comments, and URLs
142
142
  --select IDS only run these rules (comma-separated ids or categories)
143
143
  --ignore IDS skip these rules
144
+ --strict run every rule, including the off-by-default ones
144
145
  ```
145
146
 
146
147
  `check` is the default command. A first argument that is not a command name is
@@ -165,7 +166,7 @@ Three codes carry the contract. A crash just exits nonzero on its own.
165
166
 
166
167
  Empty or whitespace-only input is exit 2, like a mistyped rule id: a scan of
167
168
  nothing must not report as a clean scan. The text is tested before
168
- `--markdown` blanks code and URLs, so a file that holds only a fenced code
169
+ `--markdown` blanks code, HTML comments, and URLs, so a file that holds only a fenced code
169
170
  block still exits 0. Only when every source is empty.
170
171
 
171
172
  ## Note (the diagnostic object)
@@ -262,7 +263,7 @@ Categories (for `--select`/`--ignore` by group):
262
263
 
263
264
  - `rhetorical-tic` — the cadence patterns (the user's list below)
264
265
  - `puffery` — Wikipedia "words to watch" (boasts, vibrant, nestled, tapestry…)
265
- - `structure` — rule-of-three, "not just X but Y", "the question isn't X, it's Y", "less about X more about Y", the trailing significance participle, em dash, em-dash overuse
266
+ - `structure` — rule-of-three, "not just X but Y", "the question isn't X, it's Y", "less about X more about Y", the trailing significance participle, the trailing restatement, em dash, em-dash overuse
266
267
  - `hedging` — vague attribution ("some critics argue", "it is widely regarded")
267
268
 
268
269
  Severities: `warning` for strong tells, `info` for weak/contextual ones. No
@@ -298,6 +299,7 @@ false-positive risk, which none has demonstrated.
298
299
  | `is-the-entire` | "X is the entire point/game/business model" | |
299
300
  | `the-entire-is` | "the entire point/game/… is" (flip of above) | |
300
301
  | `is-real-and-not` | "the X is real, and/not…", "is the real … and it" | skip "real estate/time"; `info` |
302
+ | `real-x-real-y` | the same "real" used twice attributively in one sentence, in front of two different nouns: "real API calls … real credentials" | needs no noun list -- narrowed by requiring two attributive uses of the intensifier naming two different things; a hyphen on either side of "real" (real-time, non-real) takes it out of the running; a closed list drops the fixed senses (real time, real-world, real numbers/roots, real user (monitoring), real estate, real money) and the function words that continue a predicative "is real" |
301
303
  | `the-punchline-is` | "the punchline is/:/?", "the honest answer/version is" | "short version" left out; ordinary writing |
302
304
  | `worth-naming` | "worth naming/flagging/separating/spelling out" | skip "naming names"; yields to the rule below when a manner adverb follows; `info` |
303
305
  | `worth-saying-plainly` | "it's worth saying plainly / better put bluntly…", plus the bare "Put plainly," / "Said bluntly," | sentence-initial; the bare branch drops "simply"/"clearly" so "put simply" and "simply put" stay clean |
@@ -323,7 +325,9 @@ false-positive risk, which none has demonstrated.
323
325
  | `honest-x` | "an honest comparison", "the honest framing" | short noun list; "answer", "assessment", "account" excluded as ordinary; superlative yielded to the rule below |
324
326
  | `most-honest-x` | "the most honest framing", "the most honest way to put it" | wider noun list than `honest-x`; no human nouns, so "the most honest person" stays out |
325
327
  | `genuinely` | any "genuinely" | off by default; no narrowing holds |
328
+ | `actually-not-x` | "actually" and a trailing "…, not X" in one clause | the comma before "not" must be the first comma of the clause and must follow a word, so a fronted setup ("Despite the name, …") and a parenthetical both drop it; never crosses a line break; bare "actually" and "not actually" both left out |
326
329
  | `epistrophe` | two clauses ending on the same two-word phrase, the second closing the sentence | two backreferences, so the phrase may be hard-wrapped; no article-led phrase, second word 4+ letters, second clause 5–60 chars with no internal punctuation and capped whitespace; off by default; `info` |
330
+ | `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; `info` |
327
331
  | `and-thats-fine` | "And that's fine/okay." as a whole sentence | "and" required; must open and close the sentence |
328
332
  | `and-nothing-else` | trailing ", and nothing else/more/further", ", and no more" | tail must close the sentence; comma required; bare "no more" needs "and"; "?" excluded |
329
333
  | `nothing-else-frag` | the same exclusion as a fragment: "Nothing else." | sentence-initial capital; semicolon excluded; must be the whole sentence; "No more." left out |
@@ -350,11 +354,13 @@ Single flat rule per word-cluster, matched as whole words:
350
354
  - `np-fragment-and` — a whole sentence made of two noun phrases and an "and", opening on A/An/One at a sentence start or after a list marker ("A named owner and a quarterly review."). One to three words a side, no auxiliary or modal anywhere (contractions included); a lexical verb is invisible, so "A car and a truck collided." flags, which is why it ships at `info`.
351
355
  - `quip-question` — the verbless opening question ("No invite?", "New to the tool?", "Still stuck?"): sentence-initial, one of a short list of opening words, one to four more words, no auxiliary or contraction, closing on the question mark; "Need" and "Want" are left out as elided verbs; `info`, because people ask the same shape in conversation.
352
356
  - `mic-drop-closer` — a sentence of 60+ characters, then a paragraph-final closer of two to eight words opening on a **quantifier** (Nothing, Most, None, Everything, Everyone, Nobody, Then, Neither, Both): "Nothing here needs a new login."; `info`. The bare demonstratives (That, This, It) were in the list and are out: procedural writing ends a step with one as a matter of course ("This completes the roughing operations."). A blank line or the end of the text must follow the closer; both sentences may be hard-wrapped; whitespace runs in the long sentence are capped so blanked Markdown cannot make it, and the long-sentence prefix is an atomic group so an unpunctuated stretch cannot send it into catastrophic backtracking. The note points at the closer. One means nothing; a draft where it repeats is the tell.
357
+ - `ellipsis-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."; `info`. 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.
353
358
  - `short-run` — three consecutive sentences of thirty characters or fewer, each letter-led and closing on a full stop, no quotation marks or digits, no lone-letter labels (either case, though a possessive is not one) or abbreviations, starting at a real sentence boundary (never on a wrap continuation), crossing a hard wrap but not a paragraph break; `info`. A list marker may open a run but never sit inside one, so consecutive bullets are a list — the marker set covers glyphs, the literal "o" plain-text documents use as a bullet, and numbered and lettered items. Two whole-run exclusions keep document furniture out, each a property of the run rather than of any sentence in it: three single-word sentences in a row is a citation line ("Natl. Inst. Stand. Technol."), and three lowercase-led sentences in a row is transcribed speech ("we all get gas. we go to divert to Albany."). One single-word sentence is the archetypal kicker and stays, and a run that reaches a capital anywhere is prose, so identifier-initial writing ("npm was slow. git blame helped. We moved on.") is untouched. One is a question; a draft that repeats it is the tell.
354
359
  - `question-isnt` — "the question isn't/is not (whether|if|how|what|why|who) X, it's/but Y"; `info`. The resolving clause is required, so a plain rhetorical question never matches; "the real question is" is excluded.
355
360
  - `less-about-more-about` — "it's/this is/that's less about X (and) more about Y", also "… than about Y"; `info`. Both halves of the frame are required, and the subject slot is limited to the pronouns.
356
361
  - `trailing-significance-participle` — comma plus a participle from a closed verb list (highlighting, showcasing, reinforcing, shaping, enhancing, cementing, solidifying, embodying, fostering, facilitating, signalling), the clause a model hangs off a sentence to say what a fact means. Guards drop gerund lists and "signalling to". `driving`, `representing`, `reflecting`, `marking`, `contributing`, `illustrating`, `demonstrating`, `emphasising`, `echoing` and `affirming` stay out: humans write them in the same position, usually with a person as the subject, and the pattern cannot see the subject. `underscoring` is left to `underscores-highlights`.
357
- - `rule-of-three` — three parallel comma items ending a sentence (heuristic; `info` severity, off by default via `--select` since it false-positives).
362
+ - `trailing-restatement` — comma plus "which means", "which is to say", or "meaning" opening on one of a closed set of determiners and pronouns; or one of four participle frames with a pronoun object and a closing word ("making it easier", "allowing us to", "giving them more", "leaving you with"). The closer on "making" is a comparative that ends the clause or leads into "to", "for" or "than". Nothing before the comma is inspected, so a gloss ("_ma_, which means hand") matches; the bare participles ("leaving the door open") never do. Off by default: the pattern sees the connective, not whether the tail restates the head. `info`.
363
+ - `rule-of-three` — three single-word comma items ending a sentence (heuristic; `info` severity, off by default; runs under `--select` or `--strict` since it false-positives).
358
364
  - `em-dash` — any em dash; `info`.
359
365
  - `em-dash-overuse` — 3+ em dashes in one paragraph; `warning`.
360
366
 
@@ -364,8 +370,8 @@ Single flat rule per word-cluster, matched as whole words:
364
370
 
365
371
  ## Markdown handling
366
372
 
367
- `--markdown` blanks out fenced code (```` ``` ````), inline code (`` ` ``), and
368
- URLs before scanning, replacing them with same-length whitespace so line/column
373
+ `--markdown` blanks out fenced code (```` ``` ````), inline code (`` ` ``), HTML
374
+ comments (`<!-- -->`), and URLs before scanning, replacing them with same-length whitespace so line/column
369
375
  stay correct. Off by default (plain-text mode) so it never silently eats prose.
370
376
 
371
377
  ## Agent-first help text
data/lib/sloplint/cli.rb CHANGED
@@ -19,7 +19,14 @@ module Sloplint
19
19
  opts = { format: "full" }
20
20
  parser = global_parser(opts, out:)
21
21
  # Split global options from the subcommand and its args.
22
- parser.order!(argv)
22
+ begin
23
+ parser.order!(argv)
24
+ rescue OptionParser::InvalidOption => e
25
+ # `check` is the default command, so its options are accepted before
26
+ # any command word: `sloplint --markdown -`. The global parser does not
27
+ # know them, so put the option back and let check's parser judge it.
28
+ argv.unshift("check", *e.args)
29
+ end
23
30
  return 0 if opts[:help_shown] || opts[:version_shown]
24
31
 
25
32
  command = argv.shift
@@ -46,15 +53,17 @@ module Sloplint
46
53
  # ── check ───────────────────────────────────────────────────────────────
47
54
  def cmd_check(argv, opts, out:, err:, stdin:)
48
55
  markdown = false
56
+ strict = false
49
57
  select = nil
50
58
  ignore = nil
51
59
  p = OptionParser.new do |o|
52
60
  o.banner = "usage: sloplint check [options] [paths...] (\"-\" or no paths = stdin)"
53
61
  o.on("-o", "--output-format FORMAT", %w[full json],
54
62
  "Output format: 'full' or 'json' (may also be given before the command).") { |v| opts[:format] = v }
55
- o.on("--markdown", "Skip fenced/inline code spans and URLs before scanning.") { markdown = true }
63
+ o.on("--markdown", "Skip fenced/inline code spans, HTML comments, and URLs before scanning.") { markdown = true }
56
64
  o.on("--select IDS", "Only run these rules (comma-separated rule ids or category names).") { |v| select = v.split(",").map(&:strip) }
57
65
  o.on("--ignore IDS", "Skip these rules (comma-separated rule ids or category names).") { |v| ignore = v.split(",").map(&:strip) }
66
+ o.on("--strict", "Run every rule, including the ones that are off by default.") { strict = true }
58
67
  end
59
68
  p.order!(argv)
60
69
 
@@ -65,7 +74,7 @@ module Sloplint
65
74
  return 2
66
75
  end
67
76
 
68
- rules = select_rules(select, ignore)
77
+ rules = select_rules(select, ignore, strict)
69
78
  paths = argv.empty? ? ["-"] : argv
70
79
  by_path = paths.reject { |x| x == "-" }.size > 1
71
80
 
@@ -190,9 +199,11 @@ module Sloplint
190
199
 
191
200
  # --select/--ignore accept rule ids or category names. Default set excludes
192
201
  # default_on:false rules unless they are explicitly selected.
193
- def select_rules(select, ignore)
202
+ def select_rules(select, ignore, strict = false)
194
203
  rules = if select
195
204
  RULES.select { |r| select.include?(r.id) || select.include?(r.category) }
205
+ elsif strict
206
+ RULES
196
207
  else
197
208
  RULES.select(&:default_on)
198
209
  end
@@ -14,7 +14,7 @@ module Sloplint
14
14
 
15
15
  module_function
16
16
 
17
- # text: the source. rules: which Rule objects to run. markdown: blank code/URLs first.
17
+ # text: the source. rules: which Rule objects to run. markdown: blank code, HTML comments and URLs first.
18
18
  # path: label carried into each Note (e.g. filename or "-" for stdin).
19
19
  def scan(text, rules: RULES, markdown: false, path: "-")
20
20
  source = text
@@ -95,14 +95,13 @@ module Sloplint
95
95
  starts
96
96
  end
97
97
 
98
- # Replace fenced code, inline code, and URLs with same-length whitespace so
99
- # line/column stay correct. Newlines are preserved.
98
+ # Replace fenced code, HTML comments, inline code, and URLs with same-length
99
+ # whitespace so line/column stay correct. Newlines are preserved. One pass
100
+ # with one alternation, so whichever construct opens first is the one that
101
+ # gets consumed: a `<!--` quoted inside backticks is inline code, and a
102
+ # backtick inside a comment is part of the comment.
100
103
  def blank_markdown(text)
101
- blank = lambda { |s| s.gsub(/[^\n]/, " ") }
102
- text
103
- .gsub(/```.*?```/m) { |s| blank.call(s) } # fenced code
104
- .gsub(/`[^`\n]*`/) { |s| blank.call(s) } # inline code
105
- .gsub(%r{https?://\S+}) { |s| blank.call(s) } # bare URLs
104
+ text.gsub(/```.*?```|<!--.*?-->|`[^`\n]*`|https?:\/\/\S+/m) { |s| s.gsub(/[^\n]/, " ") }
106
105
  end
107
106
  end
108
107
  end
@@ -53,6 +53,35 @@ module Sloplint
53
53
  # letter is a letter.
54
54
  WHOLE_CLOSERS = /point|game|thing|deal|story|ballgame|ball#{WRAP_GAP}+game|(?:value|fix)(?=[^[:word:][:space:]'’-]|#{WRAP_GAP}+(?:[^[:word:][:space:]*_`"'‘“(\[{~]|\d+[.)][ \t])|#{WRAP_GAP}*(?:\z|#{PARA_BREAK}|(?:of|to|for|in|on|at|with|here|there|behind|right|though|now|anyway|really|from|over|after|and|but|so|as|that|which|if|when|because|since|unless|until|once|while|where|i|we|you|he|she|it|they|the|a|an|this|these|those|every|any|my|our|your|his|her|their|its)\b))/i
55
55
 
56
+ # Nouns that only ever name the writer's own construction -- never a
57
+ # concrete object, a person, or an idiom -- and that all four of
58
+ # clean-x, cleanest-x, honest-x and most-honest-x already matched before
59
+ # this constant existed. "comparison" and "through-line" read the same way
60
+ # but are each missing from one rule's original list (clean-x never had
61
+ # "comparison", cleanest-x never had "through-line"), so both stay out of
62
+ # this constant and inline in the three rules that carry them -- putting
63
+ # either one here would add a match the fourth rule never had.
64
+ WRITERS_OWN_CONSTRUCTION_NOUNS = "framing|formulation|mapping|abstraction"
65
+
66
+ # A sentence of at least sixty characters, ending in sentence-ending
67
+ # punctuation followed by one or two spaces -- the setup mic-drop-closer's
68
+ # kicker needs before it looks for the closer. The atomic group (?>...) is
69
+ # load-bearing against catastrophic backtracking; see the comment on
70
+ # mic-drop-closer's pattern for the incident that made it necessary.
71
+ SENTENCE_OF_SIXTY_CHARACTERS_ENDING_IN_PUNCTUATION_AND_SPACE =
72
+ /(?:^|(?<=[.!?])[ \t]{1,2})(?>(?:[^.!?\n\s]|(?<![ \t])[ \t]{1,2}(?![ \t])|\r?\n(?!\s*\n)[ \t]*){60,})[.!?][ \t]{1,2}/
73
+
74
+ # real-x-real-y interpolates this fragment after each of its two "real"s, so
75
+ # a later narrowing can only ever apply to both at once -- two copies typed
76
+ # out separately could drift, one gaining an exception the other never
77
+ # sees. Function words that only ever continue a predicative "is real",
78
+ # then the fixed senses common in this register that are never the
79
+ # doubled-intensifier tell: real time, real-world, the math sense (real
80
+ # numbers/roots), real user (monitoring), real estate, real money.
81
+ REAL_X_REAL_Y_EXCLUDED_NEXT_WORD =
82
+ "and|but|or|nor|yet|too|enough|itself|indeed|which|that|here|there|so" \
83
+ "|time|world|numbers?|roots?|money|estate|user"
84
+
56
85
  RULES = [
57
86
  # ── rhetorical-tic ────────────────────────────────────────────────────
58
87
  Rule.new(
@@ -343,6 +372,90 @@ module Sloplint
343
372
  "which is why the rule is off by default; when selected, several in a " \
344
373
  "draft should be read as a warning."
345
374
  ),
375
+ Rule.new(
376
+ id: "phrase-echo",
377
+ category: "rhetorical-tic",
378
+ severity: "info",
379
+ default_on: false,
380
+ # The same three words again a few paragraphs on. Three consecutive
381
+ # words, each four characters or more and lowercase-led, one of them
382
+ # six letters with nothing but letters, and the same three again
383
+ # within about 400 words. That is the whole defence, and it is
384
+ # structural: length drops the function-word runs, the all-letters
385
+ # word drops the contractions and hyphenated compounds that would
386
+ # otherwise pass on characters alone, case drops the names and
387
+ # headings. The gaps inside the phrase are capped at two spaces, as
388
+ # in epistrophe, so a code span blanked by --markdown cannot weld two
389
+ # words into a phrase.
390
+ #
391
+ # The window is a lazy walk of word steps in an atomic group, so
392
+ # nothing backtracks, and it counts Unicode words, since `\w` is
393
+ # ASCII in Ruby. Each gap in the walk is capped at 80 non-word
394
+ # characters and refuses to cross into a list item or a table row:
395
+ # a blanked code fence, a rule of dashes or a bullet is a wall, not a
396
+ # step. The repeat sits in a lookahead so the match, and the excerpt,
397
+ # is the first occurrence alone rather than the whole span; the
398
+ # suggestion says so. The repeat may not open on a quote mark, a
399
+ # backtick, an emphasis marker, a hyphen or a table bar: a quoted
400
+ # self-repeat is deliberate, and "re-shared" is not "shared". One
401
+ # backreference per word so either occurrence may be hard-wrapped.
402
+ #
403
+ # Off by default: in reference prose the rate runs to thousands per
404
+ # million words, all of it terms of art and running epithets.
405
+ pattern: /(?<![\p{Word}'’-])
406
+ (?=(?:[\w'’-]+(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})){0,2}[a-z]{6,}(?![\w'’-]))
407
+ ([a-z][\w'’-]{3,})(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
408
+ ([a-z][\w'’-]{3,})(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
409
+ ([a-z][\w'’-]{3,})\b
410
+ (?=(?>(?:(?!\r?\n[ \t]*(?:[-*+•|]|\d+[.)])[ \t])\P{Word}){1,80}\p{Word}+){0,400}?
411
+ (?:(?!\r?\n[ \t]*(?:[-*+•|]|\d+[.)])[ \t])[^\p{Word}"“‘'`*|-]){1,80}
412
+ \1(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
413
+ \2(?:[ \t ]{1,2}|\r?\n(?![ \t ]*\r?\n)[ \t ]{0,4})
414
+ \3\b)/x,
415
+ message: "Three-word phrase that comes back within a few hundred words -- a model reusing its own output.",
416
+ suggestion: "This is the first use and the repeat is ahead. Reword the repeat, unless the phrase is a term the reader needs to see again.",
417
+ examples_bad: [
418
+ "The shortest honest answer that came out of the review was a list.\n\nWhen you write back, the shortest honest answer you can send is the list.",
419
+ "We keep a shared review checklist in the repo, and it is short. Everyone who opens a pull request edits the shared review checklist first.",
420
+ # A hard-wrapped first occurrence.
421
+ "We keep a shared review\nchecklist in the repo. Everyone edits the shared review checklist first.",
422
+ # The last word inside the window.
423
+ "We keep a shared review checklist. #{"word " * 399}The shared review checklist is short."
424
+ ],
425
+ examples_ok: [
426
+ # Function words fall out on length.
427
+ "In order to ship we cut scope, and in order to ship again we cut it more.",
428
+ # Four-letter words alone are not enough; one word must be six letters, letters only.
429
+ "It would have been better, and it would have been faster.",
430
+ "It couldn't have been worse, and it couldn't have been better.",
431
+ "We saw every top-ten list here, and every top-ten list there.",
432
+ # Proper nouns and Title Case headings fall out on case.
433
+ "Grand Central Station has one, and Grand Central Station wants two.",
434
+ "Incident Response Plan\n\nThe Incident Response Plan covers the first hour.",
435
+ # The repeat is beyond the window, in words, in non-ASCII words, or past a wall of dashes.
436
+ "We keep a shared review checklist. #{"word " * 400}The shared review checklist is short.",
437
+ "We keep a shared review checklist. #{"слово " * 400}The shared review checklist is short.",
438
+ "We keep a shared review checklist.\n\n#{"-" * 100}\n\nThe shared review checklist is short.",
439
+ # A quoted repeat is deliberate, whatever the quote mark.
440
+ "The shared review checklist is new. He wrote \"shared review checklist\" on the board.",
441
+ "The shared review checklist is new. He wrote 'shared review checklist' on the board.",
442
+ "The shared review checklist is new. He wrote `shared review checklist` on the board.",
443
+ # A different word form is a different phrase, and so is a hyphenated compound.
444
+ "The shared review checklist grew, and then both shared review checklists grew.",
445
+ "The auto-generated review checklist was long. The hand-generated review checklist was longer.",
446
+ "We use a shared review checklist daily. Nobody re-shared review checklist edits.",
447
+ # A run of spaces where --markdown blanked a code span does not weld a phrase.
448
+ "We keep a shared review checklist here. Everyone edits the shared review checklist first.",
449
+ # List items and table rows are furniture, not prose.
450
+ "- shared review checklist covers pull requests\n- shared review checklist covers deploys",
451
+ "1. Update the shared config file.\n2. Restart the shared config file watcher.",
452
+ "| Task | Status |\n|---|---|\n| A | needs manual review |\n| B | needs manual review |"
453
+ ],
454
+ rationale: "A model reuses a phrase it has just minted because its own recent output is " \
455
+ "the likeliest continuation, so the same three words turn up again a few " \
456
+ "paragraphs on, doing no new work. Terms of art repeat too, and the pattern " \
457
+ "cannot tell a coined phrase from a name, so the rule is off by default."
458
+ ),
346
459
  Rule.new(
347
460
  id: "did-not-x-did-not-y",
348
461
  category: "rhetorical-tic",
@@ -892,9 +1005,9 @@ module Sloplint
892
1005
  # in front of a speech verb, and the concrete-capable nouns (cut, line,
893
1006
  # version, split) are left out entirely.
894
1007
  pattern: /\bcleanest\s+(?:\w+\s+){0,2}
895
- (?:framing|formulation|statement|account|argument|idea|definition|summary
896
- |reading|take|point|story|explanation|distinction|comparison|mapping
897
- |abstraction)\b
1008
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|comparison
1009
+ |statement|account|argument|idea|definition|summary
1010
+ |reading|take|point|story|explanation|distinction)\b
898
1011
  |\bcleanest\s+way\s+to\s+(?:say|put|frame|state|describe|phrase|express
899
1012
  |think\s+about)\b/ix,
900
1013
  message: '"The cleanest framing/way to put it…" ranks your own claim for the reader.',
@@ -922,7 +1035,7 @@ module Sloplint
922
1035
  # "break" is admitted only in "clean break between", never bare, because
923
1036
  # "make a clean break with the past" is an idiom and not a tell.
924
1037
  pattern: /\b(?:a|the|one)\s+(?:\w+\s+)?clean\s+(?:\w+\s+)?
925
- (?:abstraction|distinction|framing|formulation|mapping|through-line
1038
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|distinction|through-line
926
1039
  |story|answer|argument|split|divide)\b
927
1040
  |\bclean\s+(?:line|break|split)\s+between\b/ix,
928
1041
  message: '"A clean abstraction / clean framing" praises the idea instead of showing it.',
@@ -997,6 +1110,104 @@ module Sloplint
997
1110
  "are three words apart and identical on the surface. An agent reading the flag " \
998
1111
  "has the rest of the sentence to judge; the pattern alone doesn't."
999
1112
  ),
1113
+ Rule.new(
1114
+ id: "real-x-real-y",
1115
+ category: "rhetorical-tic",
1116
+ severity: "info",
1117
+ # Ships at info, not warning: the only two hits the probe found in
1118
+ # 1.28M words were both false positives, and one corpus of one
1119
+ # register is thin evidence next to honestly, which sits at warning
1120
+ # on 9.7M words across two. A doubled "real" is also defensible on
1121
+ # its own ("No real financial loss, but a real failure") in a way
1122
+ # the warning rules around it are not. The agent reading the flag
1123
+ # decides.
1124
+ #
1125
+ # "If it can trigger real API calls or hold real credentials, it needs
1126
+ # the same rigor." -- the same intensifier, said twice in one sentence,
1127
+ # each time in front of a different noun. Bare "real" is an ordinary
1128
+ # word (a real number, real time, a real problem) and cannot be flagged
1129
+ # on its own; the narrowing here is repetition, not a noun list --
1130
+ # requiring two attributive uses in one sentence, naming two different
1131
+ # things, is what tells the doubled intensifier apart from plain
1132
+ # English, and it needs no list of nouns to do it.
1133
+ #
1134
+ # "Attributive" is enforced by requiring "real" to run straight into
1135
+ # the word it modifies: a space, then a letter, with no comma or
1136
+ # conjunction between. That already keeps the predicative use out
1137
+ # ("The risk is real, and it is growing" -- its own rule, above) since
1138
+ # a predicate "real" is followed by punctuation or "and", never
1139
+ # directly by the next noun.
1140
+ #
1141
+ # A hyphen touching "real" on either side takes it out of the running:
1142
+ # "real-time" and "real-world" are compounds, one modifier, not two
1143
+ # independent uses of the intensifier, and "non-real" is a negation,
1144
+ # not the intensifier at all. (?<!-) and (?!-) drop all three.
1145
+ #
1146
+ # The second "real" must name a word the first one didn't -- a
1147
+ # backreference, so "a real risk ... that real risk" (the same thing,
1148
+ # referred to twice) is ordinary reference, not the tic. Two mentions
1149
+ # only look alike when they're both new: two different real things.
1150
+ #
1151
+ # The closed list after each "real" (REAL_X_REAL_Y_EXCLUDED_NEXT_WORD,
1152
+ # above) is the handful of fixed senses that are common in this
1153
+ # register and are never the tell, plus the function words that only
1154
+ # ever continue a predicative "is real". This gives up "real user"
1155
+ # outside "real user monitoring" and any doubled "real name"/"real
1156
+ # names" pairing that differs only by a plural -- known, deliberate
1157
+ # recall losses rather than a growing list.
1158
+ #
1159
+ # The gap between the two "real"s crosses a hard-wrapped line the same
1160
+ # way WRAP_GAP does (a newline is fine unless it opens a paragraph
1161
+ # break, PARA_BREAK, since a non-breaking space alone on the line in
1162
+ # between still reads as a blank line to the editors that emit one),
1163
+ # but never a sentence-ending mark, so the two "real"s must fall in
1164
+ # one sentence.
1165
+ pattern: /
1166
+ (?<!-)\breal(?!-)[ \t]+
1167
+ (?!(?:#{REAL_X_REAL_Y_EXCLUDED_NEXT_WORD})\b)
1168
+ (?>([a-z][\w'’-]*))\b
1169
+ (?:(?!\breal\b)[^.!?\n]|(?!#{PARA_BREAK})\r?\n){1,200}?
1170
+ (?<!-)\breal(?!-)[ \t]+
1171
+ (?!(?:#{REAL_X_REAL_Y_EXCLUDED_NEXT_WORD})\b)
1172
+ (?!\1\b)
1173
+ (?>[a-z][\w'’-]*)\b
1174
+ /ix,
1175
+ message: 'Doubled "real" ("real X … real Y") repeats the intensifier for emphasis.',
1176
+ suggestion: "Cut one 'real,' or say what actually makes each thing real -- a name, a number, a log line.",
1177
+ examples_bad: [
1178
+ "If it can trigger real API calls or hold real credentials, it needs the same rigor.",
1179
+ "The demo used real customer data and exposed real financial records to the whole team.",
1180
+ "This isn't a mockup; it hits a real database and charges a real credit card.",
1181
+ "The incident caused real financial losses and real reputational damage to the company."
1182
+ ],
1183
+ examples_ok: [
1184
+ # The second "real" must name something the first didn't.
1185
+ "The system exposes a real risk, and that real risk must be tracked.",
1186
+ # A hyphen on either side makes it a compound, not two uses.
1187
+ "The dashboard shows real-time metrics and real-time alerts.",
1188
+ # "real world" is on the closed list even unhyphenated.
1189
+ "In the real world this rarely happens, and the real world rewards patience.",
1190
+ # The math sense, both nouns.
1191
+ "The equation has two real roots and no real numbers outside that range.",
1192
+ # "real user" (monitoring) and a hyphenated compound together.
1193
+ "The tool combines real user monitoring with real-time dashboards.",
1194
+ # Two more closed-list nouns.
1195
+ "She invested in real estate and lost real money.",
1196
+ # "non-real" is a negation, not a second use of the intensifier.
1197
+ "The audit flagged real deployment risk but no non-real anomalies.",
1198
+ # Only one "real" in the sentence.
1199
+ "This is a real problem worth solving.",
1200
+ # A paragraph break -- even one where the blank line holds only a
1201
+ # non-breaking space -- ends the sentence; the two paragraphs are
1202
+ # never joined into one hit.
1203
+ "This plan needs real signoff\n \nbefore it touches real production data."
1204
+ ],
1205
+ rationale: "\"Real\" is the plainest way to say a thing isn't fake or hypothetical, and it " \
1206
+ "only needs saying once a sentence -- naming it again in front of a second noun " \
1207
+ "doesn't add information, it repeats the reassurance. A person defending a claim " \
1208
+ "from two directions in the same breath writes the two facts and lets one " \
1209
+ "\"real\" cover both."
1210
+ ),
1000
1211
  Rule.new(
1001
1212
  id: "the-punchline-is",
1002
1213
  category: "rhetorical-tic",
@@ -2035,8 +2246,7 @@ module Sloplint
2035
2246
  # superlative belongs to most-honest-x alone.
2036
2247
  pattern: /\b(?:an|the|one|this|that)[ \t]+(?:(?!most\b|more\b)\w+[ \t]+)?
2037
2248
  honest[ \t]+(?:\w+[ \t]+)?
2038
- (?:comparison|framing|formulation|accounting|abstraction|mapping
2039
- |through-line)\b/ix,
2249
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|comparison|accounting|through-line)\b/ix,
2040
2250
  message: '"An honest comparison / the honest framing" praises the writing, not the thing.',
2041
2251
  suggestion: "Cut the adjective and make the comparison; the reader decides if it is honest.",
2042
2252
  examples_bad: [
@@ -2073,9 +2283,10 @@ module Sloplint
2073
2283
  # politician" is, so no human nouns go in. Nothing in either corpus
2074
2284
  # matches. "way to" gets its own branch, mirroring cleanest-x.
2075
2285
  pattern: /\bmost[ \t]+honest[ \t]+(?:(?!way\b)\w+[ \t]+){0,2}
2076
- (?:comparison|framing|formulation|accounting|assessment|appraisal
2077
- |reading|account|answer|version|summary|take|signal|abstraction
2078
- |mapping|through-line)\b
2286
+ (?:#{WRITERS_OWN_CONSTRUCTION_NOUNS}|comparison
2287
+ |accounting|assessment|appraisal
2288
+ |reading|account|answer|version|summary|take|signal
2289
+ |through-line)\b
2079
2290
  |\bmost[ \t]+honest[ \t]+way[ \t]+to[ \t]+
2080
2291
  (?:say|put|frame|state|describe|phrase|think[ \t]+about)\b/ix,
2081
2292
  message: '"The most honest framing…" ranks your own claim for the reader.',
@@ -2126,6 +2337,72 @@ module Sloplint
2126
2337
  "nominally free -- and nothing in the sentence marks which use is which, " \
2127
2338
  "which is why this one runs only when you ask for it."
2128
2339
  ),
2340
+ Rule.new(
2341
+ id: "actually-not-x",
2342
+ category: "rhetorical-tic",
2343
+ severity: "warning",
2344
+ # Two markers that each correct the reader, doubled up in one clause:
2345
+ # the adverb and the trailing "…, not X". Bare "actually" is not the
2346
+ # tell and is not matched, because "the build actually failed on the
2347
+ # second run" names which run and that is a fact with something behind
2348
+ # it to check.
2349
+ #
2350
+ # The narrowing is one structural constraint, not a word list: the
2351
+ # comma in front of "not" has to be the first comma of its clause and
2352
+ # has to follow a word. A writer correcting an assumption the reader
2353
+ # does hold names it first, and that setup is either a fronted clause
2354
+ # with a comma after it ("Despite the name, …") or a parenthetical
2355
+ # whose closing bracket the comma follows. No earlier comma means
2356
+ # nothing in the clause set the alternative up. A clause starts at the
2357
+ # beginning of the text, at a full stop, question mark, exclamation
2358
+ # mark, semicolon or colon, or at a line break.
2359
+ #
2360
+ # Nothing crosses a line break, so the rule cannot weld two rows of a
2361
+ # table or two items of a list into one correction. The deliberate
2362
+ # misses: a hard-wrapped correction, and a correction whose clause
2363
+ # carries an earlier comma of any kind, an apposition ("The report,
2364
+ # filed late, says the id is actually a byte string, not text.")
2365
+ # included. No pattern tells an apposition from a fronted setup, and
2366
+ # the rule would rather say nothing than guess.
2367
+ pattern: /(?:\A|(?<=[.;:!?\n]))[^.;:!?,\n]{0,200}\K
2368
+ \bactually\b[^.;:!?,\n]{0,120}(?<=[\p{Word}]),[ \t]*not\b/ix,
2369
+ message: '"Actually …, not X" corrects an assumption the text never offered.',
2370
+ suggestion: "State the fact plainly, or name the belief the correction answers.",
2371
+ examples_bad: [
2372
+ "The disclosure actually covered two incidents, not one.",
2373
+ "The identifier is actually a byte string, not text.",
2374
+ "It actually reads the file at startup, not on the first request.",
2375
+ # A colon opens a clause, so the correction after it is still the tell.
2376
+ "Note: the endpoint actually returns two fields, not one."
2377
+ ],
2378
+ examples_ok: [
2379
+ # The bare adverb, which names which run failed. Not matched.
2380
+ "The build actually failed on the second run.",
2381
+ # The setup sits in a fronted clause, so the correction answers
2382
+ # something the reader was given. The earlier comma drops both.
2383
+ "Despite the name, the identifier is actually a byte string, not text.",
2384
+ "Although the name suggests otherwise, it is actually a byte string, not text.",
2385
+ # The correction on its own, with no adverb in front of it.
2386
+ "The run that failed was the second, not the first.",
2387
+ # Negation in front of the adverb with no trailing correction. This
2388
+ # was the other candidate shape; every real instance of it carried
2389
+ # its own setup ("normally reserved but not actually registered"),
2390
+ # so it was rejected, and this fixture pins the rejection.
2391
+ "The provider was not actually billed for the usage.",
2392
+ # The correction's comma closes a parenthesis, and the assumption
2393
+ # being corrected is inside it.
2394
+ "The party actually sending the mail (which we assumed was the relay), not the sender.",
2395
+ # Two rows of a table, not a wrapped sentence. The rule never crosses
2396
+ # a line break, so it cannot join them.
2397
+ "Actually running processes\nThreads, not processes"
2398
+ ],
2399
+ rationale: "The adverb and the trailing \"not X\" both correct the reader, and a writer " \
2400
+ "needs one or the other, never both. Doubling them marks a contrast against " \
2401
+ "an alternative the reader was never offered: nothing said one incident, so " \
2402
+ "\"not one\" answers nobody. The setup can also sit in the sentence before, " \
2403
+ "which no pattern sees, so a flag on a correction that a previous sentence " \
2404
+ "genuinely set up is the known cost."
2405
+ ),
2129
2406
  Rule.new(
2130
2407
  id: "and-thats-fine",
2131
2408
  category: "rhetorical-tic",
@@ -2642,11 +2919,17 @@ module Sloplint
2642
2919
  severity: "info",
2643
2920
  default_on: false,
2644
2921
  pattern: /\b[\w'-]+,\s+[\w'-]+,\s+(?:and\s+)?[\w'-]+[.!?]/,
2645
- message: "Three parallel comma items closing a sentence (heuristic; high false-positive).",
2922
+ message: "Three single words in a comma series closing a sentence (heuristic; high false-positive).",
2646
2923
  suggestion: "Fine in moderation; watch for the AI habit of ending on triplets.",
2647
2924
  examples_bad: ["It was fast, cheap, and simple."],
2648
- examples_ok: ["We met on Tuesday afternoon."],
2649
- rationale: "Rule-of-three endings are a model habit, but humans use them too — off by default."
2925
+ examples_ok: [
2926
+ "We met on Tuesday afternoon.",
2927
+ # Phrasal items do not match; the closing two slots take one word each.
2928
+ "It was very fast, very cheap, and very simple."
2929
+ ],
2930
+ rationale: "Rule-of-three endings are a model habit, but humans use them too — off by default. " \
2931
+ "The closing two items must be single words; a triad of phrases is three ordinary " \
2932
+ "list items to a regex, so those are left unflagged rather than guessed at."
2650
2933
  ),
2651
2934
  # clause-triad-then was cut. The pattern (comma-clause, comma-clause,
2652
2935
  # "then" clause) had no way to require the clauses actually be parallel
@@ -2872,7 +3155,7 @@ module Sloplint
2872
3155
  # long unpunctuated stretches (the Columbia report has an 864-character
2873
3156
  # one) sent this into catastrophic backtracking, 62 seconds for a 2 KB
2874
3157
  # window and no completion on the 1.1 MB document.
2875
- pattern: /(?:^|(?<=[.!?])[ \t]{1,2})(?>(?:[^.!?\n\s]|(?<![ \t])[ \t]{1,2}(?![ \t])|\r?\n(?!\s*\n)[ \t]*){60,})[.!?][ \t]{1,2}\K
3158
+ pattern: /#{SENTENCE_OF_SIXTY_CHARACTERS_ENDING_IN_PUNCTUATION_AND_SPACE}\K
2876
3159
  (?:Nothing|Most|None|Everything|Everyone|Nobody|Then|Neither|Both)
2877
3160
  (?:,?(?:[ \t]|\r?\n(?!\s*\n))+[\w'’-]+){1,7}[.!?](?=[ \t]*(?:\r?\n[ \t]*(?:\r?\n|\z)|\z))/x,
2878
3161
  message: "A short quantifier-led closer after a long sentence is the AI kicker.",
@@ -2915,6 +3198,90 @@ module Sloplint
2915
3198
  "where the flag repeats paragraph after paragraph should be read as a " \
2916
3199
  "warning, and the fix is usually to delete the closer outright."
2917
3200
  ),
3201
+ Rule.new(
3202
+ id: "ellipsis-closer",
3203
+ category: "structure",
3204
+ severity: "info",
3205
+ # The same long-sentence-then-short-closer shape as mic-drop-closer,
3206
+ # but the tell lives in the verb, not the subject. mic-drop-closer's
3207
+ # closer opens on a quantifier and keeps a full verb with its object
3208
+ # ("Nothing here needs a new login."). This one carries no verb at
3209
+ # all: the closer's verb phrase has been elided down to the bare
3210
+ # auxiliary that would have introduced it, and the object it
3211
+ # promised never arrives -- "They found an exposed dashboard and
3212
+ # asked the agent running on it to hand over its own key. The agent
3213
+ # did." Because the tell is the missing verb, this rule needs no
3214
+ # subject list the way mic-drop-closer does, and "That", "This" and
3215
+ # "It" are not excluded here the way they are there: "This completes
3216
+ # the roughing operations." ends on a full verb with an object and
3217
+ # never matches (there is no auxiliary at the sentence's end), while
3218
+ # "This did." would, on the same subject mic-drop-closer had to bar.
3219
+ #
3220
+ # The closer is a one-to-three word subject running straight into the
3221
+ # bare auxiliary and a period, and, same as mic-drop-closer, it must
3222
+ # be the last thing in the paragraph -- a blank line or the end of
3223
+ # the text follows it. A closing quotation mark after the period is
3224
+ # not blank, so quoted dialogue never satisfies this and is excluded
3225
+ # the same way short-run excludes it. A negative lookahead drops any
3226
+ # closer that still holds "what", "that", "which", "who", "why" or
3227
+ # "how", because those mark a subordinate clause supplying its own
3228
+ # complement rather than an elided one: "Nobody knew who did." asks
3229
+ # "who did it", which the subject cap alone does not catch, since
3230
+ # "Nobody knew who" is itself only three words.
3231
+ #
3232
+ # Reuses mic-drop-closer's long-sentence prefix rather than pasting a
3233
+ # second copy of it; see that constant's comment for the atomic group
3234
+ # that keeps the scan linear.
3235
+ #
3236
+ # The negated form is bolted on with "n't" for every auxiliary except
3237
+ # two irregular ones: "can" already ends in n, so its negation is
3238
+ # "can't", not "cann't", and "will" negates to the different stem
3239
+ # "won't" rather than "willn't". Both are spelled out rather than
3240
+ # built by suffix, so the two most common contractions in the list
3241
+ # are not silently unmatchable.
3242
+ pattern: /#{SENTENCE_OF_SIXTY_CHARACTERS_ENDING_IN_PUNCTUATION_AND_SPACE}\K
3243
+ (?![^.!?\n]*\b(?:what|that|which|who|why|how)\b)
3244
+ [A-Z][\w'’-]*(?:[ \t]+[\w'’-]+){0,2}[ \t]+
3245
+ (?:(?:did|does|do|was|were|is|are|had|has|could|would|should|might|must)(?:n['’]t)?
3246
+ |can(?:['’]t)?|will|won['’]t)\.
3247
+ (?=[ \t]*(?:\r?\n[ \t]*(?:\r?\n|\z)|\z))/x,
3248
+ message: "A closer that ends on a bare auxiliary is the AI verb-phrase-ellipsis kicker.",
3249
+ suggestion: "Cut the closer, or say what actually happened.",
3250
+ examples_bad: [
3251
+ "They found an exposed dashboard and asked the agent running on it to hand over its own key. The agent did.",
3252
+ "The team spent three weeks arguing about whether the migration was worth the downtime it would cost the on-call rotation. It wasn't.",
3253
+ "The reviewer asked whether a contractor with read access to the shared drive could still open the finance folder after the offboarding ran. She could.",
3254
+ "He asked whether the on-call engineer had actually paged the second responder before escalating past the fifteen-minute window. She had.",
3255
+ # The two irregular negated forms, pinned so a later rewrite of the
3256
+ # suffix can't quietly drop them again.
3257
+ "They double-checked whether the fallback path could still serve read traffic once the primary region failed over during the drill. It can't.",
3258
+ "The team hoped the migration window would close before the seasonal freight peak began overwhelming the warehouse systems. It won't."
3259
+ ],
3260
+ examples_ok: [
3261
+ # Not the end of the paragraph.
3262
+ "They found an exposed dashboard and asked the agent running on it to hand over its own key. The agent did. We logged the incident and rotated the key within the hour.",
3263
+ # A subordinate clause, not an elided one -- caught by the wh-word guard.
3264
+ "The team spent three weeks arguing about whether the migration was worth the downtime it would cost the on-call rotation. That is what it did.",
3265
+ # No long sentence in front of it.
3266
+ "The agent did.",
3267
+ # A subject longer than three words -- the cap, not the wh-guard, excludes it.
3268
+ "The engineer who had been paged in the middle of the night finally agreed with what the on-call reviewer had been saying for the better part of an hour about the rollback plan. The whole team already did.",
3269
+ # A full verb with an object, not a bare auxiliary.
3270
+ "Each step already passed local review before it reached the pipeline that runs on every push to the shared branch. It worked.",
3271
+ # A bare demonstrative closing a step is how procedural writing ends
3272
+ # a paragraph, and it ends on a full verb, not an auxiliary. Wording
3273
+ # follows Turning and Boring (1919) and Aviation Engines (1917),
3274
+ # both public domain by date; mic-drop-closer carries the same two
3275
+ # examples for the same reason.
3276
+ "The cutting tools are set to the dimensions required for the finished work, and the stops are locked. This completes the roughing operations.",
3277
+ "The magneto is protected from oil and grit by a cover that is easy to remove for service. This means prolonged life for the magneto."
3278
+ ],
3279
+ rationale: "The closer withholds exactly the thing the long setup built toward -- the " \
3280
+ "verb and its object are gone, and only the bare confirmation that something " \
3281
+ "happened is left standing. Ships at info: plenty of ordinary writing drops " \
3282
+ "the verb the same way when it confirms an expectation, and one flag proves " \
3283
+ "nothing; a draft where it repeats is the tell."
3284
+ ),
2918
3285
  Rule.new(
2919
3286
  id: "short-run",
2920
3287
  category: "structure",
@@ -3228,6 +3595,98 @@ module Sloplint
3228
3595
  "narrator who never appears. Careful writers put the verb in its own clause " \
3229
3596
  "with a subject, or leave the significance to the reader."
3230
3597
  ),
3598
+ Rule.new(
3599
+ id: "trailing-restatement",
3600
+ category: "structure",
3601
+ severity: "info",
3602
+ default_on: false,
3603
+ # The restating tail: "…, which means working through the process
3604
+ # rather than around it". A regex sees the connective and not whether
3605
+ # the tail says the head again, and the sentence that prompted the rule
3606
+ # shares no content words between the two, so no overlap test reaches
3607
+ # it either. Hence off by default.
3608
+ #
3609
+ # Three connectives, and four participle frames that make the same
3610
+ # move. Nothing before the comma is inspected: a gloss ("_ma_, which
3611
+ # means hand") and a real tell after a code span or a bold phrase end
3612
+ # on the same characters, so a guard there costs more hits than it
3613
+ # saves. Glosses are a known cost of the rule. The participle
3614
+ # "meaning" must open on one of a closed set of determiners and
3615
+ # pronouns, which keeps out the noun ("meaning of"), the intention
3616
+ # ("meaning to come back") and the bare-noun gloss ("meaning six"); a
3617
+ # gloss that takes an article ("meaning the red stick") gets through.
3618
+ # Each participle frame needs a pronoun object and a closing word,
3619
+ # because the bare participle is ordinary English ("she left, leaving
3620
+ # the door open"). The closer on "making" is a comparative or
3621
+ # (im)possible and must end the clause or lead into "to", "for" or
3622
+ # "than", since "-er" alone is also "wonder", "offer" and "her". These
3623
+ # verbs stay off trailing-significance-participle, whose list is closed
3624
+ # to verbs an event can be the subject of.
3625
+ pattern: /,#{WRAP_GAP}+
3626
+ (?:which#{WRAP_GAP}+means\b
3627
+ |which#{WRAP_GAP}+is#{WRAP_GAP}+to#{WRAP_GAP}+say\b
3628
+ |meaning#{WRAP_GAP}+(?:that|the|a|an|this|these|those|it|he|she|we|you|they|there|nothing|every|each|any|all|most|some|your|our|their|its)\b
3629
+ |making#{WRAP_GAP}+(?:it|them|us|you|the(?:#{WRAP_GAP}+[\w'’-]+){1,2})#{WRAP_GAP}+
3630
+ (?:[a-z]+er|(?:im)?possible|(?:more|less)(?:#{WRAP_GAP}+[a-z]+)?)
3631
+ (?=#{WRAP_GAP}+(?:to|for|than)\b|[ \t]*[.,;:!?)]|[ \t]*\r?\n|[ \t]*\z)
3632
+ |allowing#{WRAP_GAP}+(?:it|them|us|you)#{WRAP_GAP}+to\b
3633
+ |giving#{WRAP_GAP}+(?:them|us|you)#{WRAP_GAP}+(?:more|less|time|room)\b
3634
+ |leaving#{WRAP_GAP}+(?:them|us|you)#{WRAP_GAP}+with(?:out)?\b)/ix,
3635
+ message: "Trailing clause that says the sentence again, or hangs a result off it.",
3636
+ suggestion: "Cut the tail, or if it states a real consequence, make it its own sentence.",
3637
+ examples_bad: [
3638
+ "We moved the checks into the build step, which means the errors show up before anyone opens a review.",
3639
+ "The new queue drains in order, which is to say nothing jumps ahead of an older job.",
3640
+ "Every job now records its own start time, meaning the log tells you when the run began.",
3641
+ "He signed at once, meaning he had read it already.",
3642
+ "We rebuilt the parser, making the whole pipeline faster.",
3643
+ "The cache is local now, making it easier to reason about.",
3644
+ "The change is small, making it more robust.",
3645
+ "The lock is per row, making it impossible for two writers to collide.",
3646
+ "The cache now lives beside the worker, allowing us to skip the round trip.",
3647
+ "The report ships on Fridays, giving them more time to read it.",
3648
+ "The old flags are gone, leaving you with one switch to learn.",
3649
+ # Nothing before the comma is inspected.
3650
+ "The flag defaults to `false`, which means nothing is written to disk.",
3651
+ "- **Cache is local**, which means the round trip is gone.",
3652
+ "We fixed the bug (the null check), which means the crash is gone.",
3653
+ # Either gap may hard-wrap.
3654
+ "We moved the checks into the build step,\nwhich means the errors show up first.",
3655
+ "We moved the checks into the build step, which\nmeans the errors show up first."
3656
+ ],
3657
+ examples_ok: [
3658
+ # "meaning" outside the closed set: the bare-noun gloss, the intention, the noun.
3659
+ "They count on with tatisitupe, meaning six.",
3660
+ "It is an old word, meaning caves.",
3661
+ "He left the room, meaning to come back before dark.",
3662
+ "She read on, meaning of the word aside, and let it pass.",
3663
+ "He shrugged, meaning no harm by it.",
3664
+ # The bare participles are ordinary English.
3665
+ "She left the room, leaving the door open.",
3666
+ "Cut the paper around the frame, leaving a margin for pasting.",
3667
+ "He handed over the keys, giving her a nod.",
3668
+ "The lid lifts off, allowing the steam to escape.",
3669
+ # A pronoun object with no closing word, or the wrong one.
3670
+ "Rub down the leather, making it as smooth as possible.",
3671
+ "The crowd parted, making room for more chairs.",
3672
+ "He pulled the cork, giving it time to breathe.",
3673
+ "He shut the door, leaving them to it.",
3674
+ "The porter took the trunk, leaving it with the station master.",
3675
+ # "-er" that is not a comparative, and a comparative that does not close the clause.
3676
+ "The rain kept up all week, making you wonder whether the trip was worth it.",
3677
+ "She turned the coat inside out, making it her own.",
3678
+ "They argued, making the same point over and over.",
3679
+ "The tide turned, making it matter less than before.",
3680
+ # A gerund list.
3681
+ "The work involves cutting, making and sanding the parts.",
3682
+ # A paragraph break is not a comma.
3683
+ "The checks moved into the build step\n\nWhich means the errors show up first."
3684
+ ],
3685
+ rationale: "The tail after the connective says the head again in other words, and a " \
3686
+ "model adds one whenever a sentence feels short of a point. Careful writers " \
3687
+ "use the same connective to state a consequence, and the pattern cannot " \
3688
+ "tell the two apart, so the rule is off by default."
3689
+ ),
3231
3690
  # ── hedging ───────────────────────────────────────────────────────────
3232
3691
  Rule.new(
3233
3692
  id: "vague-attribution",
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Sloplint
4
- VERSION = "0.6.0"
4
+ VERSION = "0.7.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.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Benjamin Jackson