sloplint 0.8.0 → 0.9.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.
data/lib/sloplint/cli.rb CHANGED
@@ -56,6 +56,10 @@ module Sloplint
56
56
  strict = false
57
57
  select = nil
58
58
  ignore = nil
59
+ judge = false
60
+ register = nil
61
+ backend = nil
62
+ help = false
59
63
  p = OptionParser.new do |o|
60
64
  o.banner = "usage: sloplint check [options] [paths...] (\"-\" or no paths = stdin)"
61
65
  o.on("-o", "--output-format FORMAT", %w[full json],
@@ -63,21 +67,143 @@ module Sloplint
63
67
  o.on("--markdown", "Skip fenced/inline code spans, HTML comments, and URLs before scanning.") { markdown = true }
64
68
  o.on("--select IDS", "Only run these rules (comma-separated rule ids or category names).") { |v| select = v.split(",").map(&:strip) }
65
69
  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 }
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 }
74
+ o.on("--backend NAME", "With --judge: which model adapter to use.") { |v| backend = v }
75
+ # OptionParser answers -h itself when nobody else does, and it answers
76
+ # it on the real stdout and ends the process. This command prints to
77
+ # the out it was given and returns, as every other one does.
78
+ o.on("-h", "--help", "Show this help.") { out.puts(o.help); help = true }
67
79
  end
68
- p.order!(argv)
80
+ # permute!, so a flag written after the path is a flag: `sloplint check
81
+ # draft.md --judge` reads the way anyone would write it.
82
+ p.permute!(argv)
83
+ return 0 if help
69
84
 
70
- unknown = unknown_rule_refs(select) + unknown_rule_refs(ignore)
85
+ # The judge is a separate gem that depends on this one. The bare require
86
+ # goes through the load path: exe/sloplint puts this checkout's lib/ at
87
+ # the front, so from the plugin tree the judge files beside this one win,
88
+ # and an installed sloplint-judge gem is found otherwise. Only a missing
89
+ # judge is the install hint; any other LoadError is a real one.
90
+ if judge
91
+ begin
92
+ require "sloplint/judge"
93
+ # A Gem::LoadError is an installed sloplint-judge that RubyGems will
94
+ # not activate beside this sloplint: a conflict, or no version that
95
+ # fits. It carries no path, and to the reader it is the same thing as
96
+ # no judge at all, which is how `--help` already reports it.
97
+ rescue LoadError => e
98
+ raise unless e.path == "sloplint/judge" || e.is_a?(Gem::LoadError)
99
+
100
+ err.puts("sloplint: --judge needs the sloplint-judge gem: gem install sloplint-judge")
101
+ # An installed judge that RubyGems will not activate is a version
102
+ # conflict, and the hint above tells the reader to install what
103
+ # they already have. RubyGems says which versions fell out, so
104
+ # print that too rather than throwing it away.
105
+ err.puts("sloplint: #{e.message}") if e.is_a?(Gem::LoadError)
106
+ return 2
107
+ end
108
+ end
109
+ # Accepted and then dropped, these two read as a judge run that was
110
+ # never asked for: `sloplint check --register "a lawyer" brief.md` runs
111
+ # the regex rules and says nothing about the reader it was given.
112
+ unless judge
113
+ { "--register" => register, "--backend" => backend }.each do |flag, value|
114
+ next if value.nil?
115
+
116
+ err.puts("sloplint: #{flag} needs --judge: without it no model is asked anything.")
117
+ return 2
118
+ end
119
+ end
120
+ catalog = judge ? RULES + Judge::RULES : RULES
121
+
122
+ unknown = unknown_rule_refs(select, catalog) + unknown_rule_refs(ignore, catalog)
71
123
  unless unknown.empty?
72
124
  err.puts("sloplint: unknown rule or category: #{unknown.join(", ")}")
73
- err.puts("run `sloplint rules` to list them.")
125
+ # Under --judge the selection is read against both catalogs, and
126
+ # `sloplint rules` lists only one of them: a judge rule id typed with
127
+ # a letter wrong is not in the list the hint would send you to.
128
+ lists = judge ? "`sloplint rules` and `sloplint-judge rules`" : "`sloplint rules`"
129
+ err.puts("run #{lists} to list them.")
74
130
  return 2
75
131
  end
76
132
 
77
- rules = select_rules(select, ignore, strict)
133
+ rules = select_rules(select, ignore, strict, catalog)
78
134
  paths = argv.empty? ? ["-"] : argv
79
135
  by_path = paths.reject { |x| x == "-" }.size > 1
80
136
 
137
+ # Only the read is invalid input. An ArgumentError out of a scan or a
138
+ # judge run is a bug in this code, and it raises like one instead of
139
+ # sending the reader to look for bad bytes in a file that has none.
140
+ sources = begin
141
+ read_sources(paths, err:, stdin:)
142
+ rescue ArgumentError, Encoding::CompatibilityError => e
143
+ err.puts("sloplint: invalid input: #{e.message}")
144
+ return 2
145
+ end
146
+ return 2 unless sources
147
+
148
+ regex_rules, judge_rules = rules.partition { |r| r.is_a?(Rule) }
149
+
150
+ judge_usage = nil
151
+ # Under --judge the output is the {"notes", "judge"} object whether or
152
+ # not a judge rule survived selection: a caller that asked for the judge
153
+ # reads the wrapper, and an empty selection is 0 requests, not a
154
+ # different output shape. 0 requests also means no key: `--judge
155
+ # --select em-dash` must run on a machine that has none.
156
+ #
157
+ # Before the regex scan, not after it: a missing key or a bad
158
+ # SYSTEMONE_URL is known without asking anything, and finding it out
159
+ # after every file has been scanned throws that work away to print the
160
+ # same message.
161
+ if judge
162
+ # With nothing to ask, no key is read: the backend is named and never
163
+ # built, and `sloplint-judge check` reports an empty selection through
164
+ # the same helper, so the two commands print one line. Either way an
165
+ # unknown --backend or a key that is not there is the same usage
166
+ # error, so one rescue answers for both.
167
+ begin
168
+ if judge_rules.empty?
169
+ judge_usage = Judge::Engine.nothing_asked(backend)
170
+ else
171
+ judge_backend = Judge::Backend.load(backend)
172
+ end
173
+ rescue ArgumentError => e
174
+ err.puts("sloplint: --judge: #{e.message}")
175
+ return 2
176
+ end
177
+ end
178
+
179
+ all_notes = sources.flat_map do |label, text|
180
+ Engine.scan(text, rules: regex_rules, markdown:, path: label)
181
+ end
182
+
183
+ if judge_usage
184
+ err.puts("sloplint: judge #{Judge::Engine.usage_line(judge_usage)}")
185
+ elsif judge_backend
186
+ begin
187
+ 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:)
189
+ # Exit 3 withholds the regex notes too: a caller that asked for both
190
+ # and got one would read it as a clean judge run.
191
+ rescue Judge::BackendError => e
192
+ err.puts("sloplint: judge backend failure: #{e.message}")
193
+ return 3
194
+ end
195
+ order = sources.each_with_index.to_h { |(label, _), i| [label, i] }
196
+ all_notes = (all_notes + judged.notes).sort_by.with_index { |n, i| [order[n.path], n.line, n.column, i] }
197
+ judge_usage = judged.usage
198
+ end
199
+
200
+ emit(all_notes, opts[:format], out:, by_path:, judge: judge_usage)
201
+ all_notes.empty? ? 0 : 1
202
+ end
203
+
204
+ # Read every path (or stdin for "-") as UTF-8. Returns [[label, text], ...]
205
+ # or nil after writing the error, so the caller exits 2.
206
+ def read_sources(paths, err:, stdin: $stdin, name: "sloplint")
81
207
  sources = []
82
208
  paths.each do |path|
83
209
  # Read as UTF-8 whatever the locale says. A sandbox with no LANG set
@@ -90,11 +216,20 @@ module Sloplint
90
216
  stdin.read.force_encoding(Encoding::UTF_8)
91
217
  else
92
218
  unless File.file?(path)
93
- err.puts("sloplint: no such file: #{path}")
94
- return 2
219
+ err.puts("#{name}: no such file: #{path}")
220
+ return nil
95
221
  end
96
222
  File.read(path, encoding: Encoding::UTF_8)
97
223
  end
224
+ # Prose that is not valid UTF-8 is an invalid input, said here rather
225
+ # than left to whatever reads the text first: a scan raises on the
226
+ # first regex, and the judge sends the text to a backend, where it
227
+ # would be a JSON error after the request was paid for.
228
+ unless text.valid_encoding?
229
+ err.puts("#{name}: invalid input: #{path == "-" ? "stdin" : path} is not valid UTF-8")
230
+ return nil
231
+ end
232
+
98
233
  sources << [path == "-" ? "-" : path, text]
99
234
  end
100
235
 
@@ -104,29 +239,19 @@ module Sloplint
104
239
  # mistyped rule id sets, and it exits 2 for the same reason.
105
240
  if sources.all? { |_, text| text.strip.empty? }
106
241
  names = sources.map { |label, _| label == "-" ? "stdin" : label }
107
- err.puts("sloplint: empty input: nothing to check in #{names.join(", ")}")
108
- return 2
109
- end
110
-
111
- all_notes = sources.flat_map do |label, text|
112
- Engine.scan(text, rules:, markdown:, path: label)
242
+ err.puts("#{name}: empty input: nothing to check in #{names.join(", ")}")
243
+ return nil
113
244
  end
245
+ sources
246
+ end
114
247
 
115
- case opts[:format]
116
- when "json"
117
- out.puts(Output.format_json(all_notes, by_path:))
248
+ def emit(notes, format, out:, by_path:, judge: nil)
249
+ if format == "json"
250
+ out.puts(Output.format_json(notes, by_path:, judge:))
118
251
  else
119
- text = Output.format_human(all_notes)
252
+ text = Output.format_human(notes)
120
253
  out.puts(text) unless text.empty?
121
254
  end
122
-
123
- all_notes.empty? ? 0 : 1
124
- # Invalid UTF-8 reaches this two ways: String#strip in the empty check
125
- # raises Encoding::CompatibilityError, the engine's regexes raise
126
- # ArgumentError. Both are the same thing to the reader.
127
- rescue ArgumentError, Encoding::CompatibilityError => e
128
- err.puts("sloplint: invalid input: #{e.message}")
129
- 2
130
255
  end
131
256
 
132
257
  # ── rules ───────────────────────────────────────────────────────────────
@@ -137,19 +262,26 @@ module Sloplint
137
262
  o.on("--json", "Emit the catalog as JSON for machine enumeration.") { as_json = true }
138
263
  end.order!(argv)
139
264
 
140
- if as_json
141
- payload = RULES.map do |r|
265
+ render_rules(RULES, json: as_json, out:)
266
+ 0
267
+ end
268
+
269
+ # The catalog as a table or as JSON. Shared with sloplint-judge, whose
270
+ # rules also carry a unit.
271
+ def render_rules(catalog, json:, out:)
272
+ if json
273
+ payload = catalog.map do |r|
142
274
  { id: r.id, category: r.category, severity: r.severity, confidence: r.confidence,
143
275
  message: r.message, rationale: r.rationale, suggestion: r.suggestion }
276
+ .merge(r.respond_to?(:unit) ? { unit: r.unit } : {})
144
277
  end
145
278
  out.puts(JSON.pretty_generate(payload))
146
279
  else
147
- RULES.each do |r|
280
+ catalog.each do |r|
148
281
  off = r.confidence == "low" ? " [off by default]" : ""
149
282
  out.puts("#{r.id.ljust(24)} #{r.category.ljust(18)} #{r.severity.ljust(8)} #{r.confidence.ljust(7)} #{r.message}#{off}")
150
283
  end
151
284
  end
152
- 0
153
285
  end
154
286
 
155
287
  # ── explain ID ────────────────────────────────────────────────────────
@@ -190,10 +322,10 @@ module Sloplint
190
322
  # ── helpers ─────────────────────────────────────────────────────────────
191
323
  # Ids/categories in refs that match no rule in the catalog. nil (no --select
192
324
  # or --ignore given) passes through as no unknowns.
193
- def unknown_rule_refs(refs)
325
+ def unknown_rule_refs(refs, catalog = RULES)
194
326
  return [] unless refs
195
327
 
196
- known = RULES.flat_map { |r| [r.id, r.category] }.uniq
328
+ known = catalog.flat_map { |r| [r.id, r.category] }.uniq
197
329
  refs - known
198
330
  end
199
331
 
@@ -201,15 +333,15 @@ module Sloplint
201
333
  # excludes low-confidence rules unless they are explicitly selected. A
202
334
  # category ref selects only that category's non-low rules unless --strict
203
335
  # is set; naming a rule by its own id still selects it whatever its
204
- # confidence.
205
- def select_rules(select, ignore, strict = false)
336
+ # confidence. catalog is RULES, or RULES plus the judge's under --judge.
337
+ def select_rules(select, ignore, strict = false, catalog = RULES)
206
338
  runs_by_default = ->(r) { r.confidence != "low" }
207
339
  rules = if select
208
- RULES.select { |r| select.include?(r.id) || (select.include?(r.category) && (runs_by_default.call(r) || strict)) }
340
+ catalog.select { |r| select.include?(r.id) || (select.include?(r.category) && (runs_by_default.call(r) || strict)) }
209
341
  elsif strict
210
- RULES
342
+ catalog
211
343
  else
212
- RULES.select(&runs_by_default)
344
+ catalog.select(&runs_by_default)
213
345
  end
214
346
  if ignore
215
347
  rules = rules.reject { |r| ignore.include?(r.id) || ignore.include?(r.category) }
@@ -217,9 +349,37 @@ module Sloplint
217
349
  rules
218
350
  end
219
351
 
220
- def global_parser(opts, out:)
221
- OptionParser.new do |o|
222
- o.banner = <<~BANNER
352
+ # The judge's part of the agent recipe, which depends on whether the gem
353
+ # is here. Looked up on the load path without loading it: help must not
354
+ # pull a model adapter in. The text says the one thing an agent must know
355
+ # before the flag: the judge sends the text to an API, so ask the person.
356
+ def judge_recipe
357
+ # Installed as a gem, the judge is not on the load path until RubyGems
358
+ # activates it, so the load path alone reports a missing gem. A gem on
359
+ # disk that asks for a different sloplint cannot be activated beside
360
+ # this one, and advertising it would send the agent into a conflict.
361
+ installed = $LOAD_PATH.resolve_feature_path("sloplint/judge") ||
362
+ Gem::Specification.find_all_by_name("sloplint-judge").any? { |spec|
363
+ spec.dependencies.find { |d| d.name == "sloplint" }&.match?("sloplint", Sloplint::VERSION)
364
+ }
365
+ unless installed
366
+ return "# sloplint-judge (not installed here) adds model-backed rules for what a regex cannot see: gem install sloplint-judge\n"
367
+ end
368
+
369
+ <<~RECIPE
370
+ # With the judge (sloplint-judge is installed here). It sends the text to api.typesafe.ai,
371
+ # so an agent asks the person before running it. Check first, ask, then run:
372
+ 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}}
374
+ # exit 3 = the model could not be reached; nothing was checked, not even the regex rules
375
+ RECIPE
376
+ end
377
+
378
+ # The banner names whether the judge gem is here, which costs a scan of
379
+ # the installed gems. Only --help reads it, so it is built when it is
380
+ # read and never on the way to a scan.
381
+ def global_banner
382
+ <<~BANNER
223
383
  sloplint — flag the rhetorical tics and puffery that mark AI-generated prose.
224
384
 
225
385
  # Recommended for agents:
@@ -228,17 +388,23 @@ module Sloplint
228
388
  # each note: {path,line,column,severity,confidence,rule,category,message,excerpt,context,rationale,suggestion,count}
229
389
  # (count is present only for the rules that tally items)
230
390
 
391
+ #{judge_recipe}
231
392
  usage: sloplint [-o full|json] [command] [args]
232
393
 
233
394
  commands:
234
395
  check scan paths (or stdin) for AI-slop tells and report notes [default]
235
396
  a first argument that is not a command name is taken as a path
397
+ --judge adds sloplint-judge's model-backed rules when that gem is installed
236
398
  rules list the rule catalog (add --json for the machine-readable form)
237
399
  explain ID print one rule's message, rationale, and a bad/ok example
238
400
  version print the sloplint version
239
401
 
240
402
  global options:
241
- BANNER
403
+ BANNER
404
+ end
405
+
406
+ def global_parser(opts, out:)
407
+ parser = OptionParser.new do |o|
242
408
  o.on("-o", "--output-format FORMAT", %w[full json],
243
409
  "Output format: 'full' (human-readable text) or 'json' (default: full).") do |v|
244
410
  opts[:format] = v
@@ -254,6 +420,8 @@ module Sloplint
254
420
  o.separator ""
255
421
  o.separator "See `sloplint explain <id>` for any rule, or docs/SPEC.md for the JSON contract."
256
422
  end
423
+ parser.define_singleton_method(:banner) { CLI.global_banner }
424
+ parser
257
425
  end
258
426
  end
259
427
  end
@@ -59,14 +59,21 @@ module Sloplint
59
59
  # over blanked text shows code and URLs as a run of spaces. Offsets here are
60
60
  # character offsets (MatchData#begin), matching the char-based line_starts_for.
61
61
  def context_for(source, match)
62
- return "[#{match[0].gsub(/\s+/, " ").strip}]" if match[0].length >= CONTEXT_CHARS
62
+ context_window(source, match.begin(0), match.end(0))
63
+ end
64
+
65
+ # The same window for any span [b, e) of source, so a tool that locates a
66
+ # sentence rather than a regex match (the judge) draws its context the
67
+ # way sloplint does.
68
+ def context_window(source, b, e)
69
+ span = source[b...e]
70
+ return "[#{span.gsub(/\s+/, " ").strip}]" if span.length >= CONTEXT_CHARS
63
71
 
64
- b, e = match.begin(0), match.end(0)
65
72
  pre = source[[b - CONTEXT_CHARS, 0].max...b]
66
73
  post = source[e, CONTEXT_CHARS].to_s
67
74
  pre = "…#{pre.sub(/\A\S*\s+/, "")}" if b > CONTEXT_CHARS
68
75
  post = "#{post.sub(/\s+\S*\z/, "")}…" if e + CONTEXT_CHARS < source.length
69
- "#{pre}[#{match[0]}]#{post}".gsub(/\s+/, " ").strip
76
+ "#{pre}[#{span}]#{post}".gsub(/\s+/, " ").strip
70
77
  end
71
78
 
72
79
  # 1-indexed line and column for a char offset into text. Binary-searches a
@@ -101,8 +108,28 @@ module Sloplint
101
108
  # with one alternation, so whichever construct opens first is the one that
102
109
  # gets consumed: a `<!--` quoted inside backticks is inline code, and a
103
110
  # backtick inside a comment is part of the comment.
111
+ # A URL ends before the punctuation that ends the sentence it sits in:
112
+ # "See https://example.com. Then do X." is two sentences, and swallowing
113
+ # the first period would make it one. A URL that really ends in one of
114
+ # these, a Wikipedia link closing on a bracket, loses that character to
115
+ # the sentence instead; the text is only being blanked, so what it costs
116
+ # is one visible character, not a broken link.
117
+ # A fence opens and closes at the start of a line, with three or more
118
+ # backticks; the opener may carry an info string and the closer nothing
119
+ # but whitespace. The line start is the shape that matters: a fence
120
+ # quoted inside a code span, which is how a document explains fences,
121
+ # opened a block in the middle of a sentence and every fence after it in
122
+ # the file paired with the wrong one.
123
+ #
124
+ # Any indent is allowed, because CommonMark measures a fence's indent
125
+ # from its container and a fence under "10. " or a nested bullet stands
126
+ # further in than three spaces. What that costs is an indented code block
127
+ # whose own content has a line of backticks in it, which is rare, and the
128
+ # only thing it costs there is more blanking.
129
+ MARKDOWN_NOISE = /(?<block>^[ \t]*`{3,}[^\n]*\n.*?^[ \t]*`{3,}[ \t]*$|<!--.*?-->)|(?<inline>`[^`\n]*`|https?:\/\/\S*[^\s.,;:!?)\]])/m
130
+
104
131
  def blank_markdown(text)
105
- text.gsub(/```.*?```|<!--.*?-->|`[^`\n]*`|https?:\/\/\S+/m) { |s| s.gsub(/[^\n]/, " ") }
132
+ text.gsub(MARKDOWN_NOISE) { |s| s.gsub(/[^\n]/, " ") }
106
133
  end
107
134
  end
108
135
  end
@@ -20,13 +20,17 @@ module Sloplint
20
20
  end
21
21
 
22
22
  # JSON: an array of notes, or an object keyed by path when >1 file was scanned.
23
- def format_json(notes, by_path: false)
24
- if by_path
25
- grouped = notes.group_by(&:path).transform_values { |ns| ns.map { |n| note_hash(n) } }
26
- JSON.pretty_generate(grouped)
23
+ # judge: when the judge ran, its backend name and usage; the notes then
24
+ # sit under "notes" and the usage under "judge", so a caller who asked for
25
+ # the model's opinion also gets what it cost.
26
+ def format_json(notes, by_path: false, judge: nil)
27
+ payload = if by_path
28
+ notes.group_by(&:path).transform_values { |ns| ns.map { |n| note_hash(n) } }
27
29
  else
28
- JSON.pretty_generate(notes.map { |n| note_hash(n) })
30
+ notes.map { |n| note_hash(n) }
29
31
  end
32
+ payload = { "notes" => payload, "judge" => judge } if judge
33
+ JSON.pretty_generate(payload)
30
34
  end
31
35
 
32
36
  def note_hash(note)
@@ -1895,6 +1895,59 @@ module Sloplint
1895
1895
  rationale: "Labelling a claim as the portable lesson does the persuading that the claim " \
1896
1896
  "should be doing — a model habit borrowed from thought-leader prose."
1897
1897
  ),
1898
+ Rule.new(
1899
+ id: "cataphoric-teaser",
1900
+ category: "self-rating",
1901
+ severity: "warning",
1902
+ confidence: "high",
1903
+ # The forward-pointing tease: "Here's what nobody tells you", "the
1904
+ # part most people get wrong". Three parts, each a closed frame. A
1905
+ # sentence-initial position, optionally opened by a forward demonstrative
1906
+ # (here's / this is; "that's what nobody understands" points backward
1907
+ # and is left alone). A noun slot: what / the part / the thing / the
1908
+ # bit / the secret. A subject that claims scarcity for the reader's
1909
+ # benefit: nobody, no one, most people, few people, hardly anyone,
1910
+ # they, people. Then up to two adverbs and a verb of telling, getting
1911
+ # wrong, talking about, admitting or noticing. The bare subject form
1912
+ # ("What nobody tells you ...") must also reach a copula or colon in
1913
+ # the same sentence, since that is what makes it a setup rather than a
1914
+ # plain report ("What nobody tells you gets forgotten."), and it may
1915
+ # not take "they": without a demonstrative in front, "what they don't
1916
+ # realize is" refers to the people the story is about, and narrative
1917
+ # says it. Read across 8.7 MB of RAID that was the one human hit.
1918
+ pattern: /(?:\A|[.!?]\s+|\n\s*\n|:\s+)\s*
1919
+ (?:(?<demo>(?:\w+,?\s+)?(?:here'?s|here\s+is|this\s+is)\s+)|(?-i:(?=[A-Z])))
1920
+ (?:what|the\s+(?:part|thing|bit|secret))\s+
1921
+ (?:nobody|no[-\s]?one|most\s+\w+(?:\s+\w+)?|few\s+people|hardly\s+anyone|almost\s+(?:nobody|no[-\s]?one)|people|(?(<demo>)they|(?!)))\s+
1922
+ (?:(?:will|won'?t|don'?t|doesn'?t|actually|ever|really|never|rarely|wants?\s+to)\s+){0,2}
1923
+ (?:tells?|gets?|talks?|says?|admits?|mentions?|warns?|teach(?:es)?|explains?|notices?|realizes?|understands?|hears?)\b
1924
+ (?(<demo>)|[^.!?\n:]{0,60}?(?:\s+is\b|:))/ix,
1925
+ message: "Cataphoric teaser promises a hidden insight before giving it.",
1926
+ suggestion: "Cut the tease and state the thing itself.",
1927
+ examples_bad: [
1928
+ "Here's what nobody tells you about hiring.",
1929
+ "Here is the part most people get wrong.",
1930
+ "This is the thing they don't tell you.",
1931
+ "What nobody tells you is that the migration is the easy part.",
1932
+ "The part nobody talks about is the rollback.",
1933
+ "What most senior engineers won't admit is that the estimate was a guess.",
1934
+ "So here's what few people realize: the cache is cold on every deploy."
1935
+ ],
1936
+ examples_ok: [
1937
+ # Backward-pointing: the sentence reports, it does not tease.
1938
+ "That's what nobody understands.",
1939
+ "That is the part most people get wrong.",
1940
+ # A bare-subject sentence that never reaches a copula is a report.
1941
+ "What nobody tells you gets forgotten by lunch.",
1942
+ # Mid-sentence, the phrase is an object, not a setup.
1943
+ "I finally learned what nobody tells you about hiring.",
1944
+ # Lowercase after a hard wrap is a continuation, not a sentence start.
1945
+ "the reviewer read it and\nwhat nobody tells you is that she signed it anyway."
1946
+ ],
1947
+ rationale: "The tease sells the claim as rare knowledge before the claim exists, so the " \
1948
+ "reader is asked to rate it before reading it. Models reach for it as an " \
1949
+ "opener because engagement training rewards the hook."
1950
+ ),
1898
1951
  Rule.new(
1899
1952
  id: "is-is",
1900
1953
  category: "cadence",
@@ -3079,6 +3132,80 @@ module Sloplint
3079
3132
  "it to close a setup; careful writers join the clauses with a conjunction " \
3080
3133
  "or give the problem its own sentence."
3081
3134
  ),
3135
+ Rule.new(
3136
+ id: "punch-sentence",
3137
+ category: "cadence",
3138
+ severity: "info",
3139
+ confidence: "medium",
3140
+ # The mid-paragraph punch: a verbless beat of one to three words wedged
3141
+ # between two long sentences. "...for the third time that quarter. Not
3142
+ # anymore. The new rota puts..." mic-drop-closer and
3143
+ # bare-auxiliary-closer take the long-then-short shape at the end of a
3144
+ # paragraph; this is the one in the middle, which the closer rules
3145
+ # refuse. The setup is the shared sixty-character prefix, and a
3146
+ # sentence of forty or more characters must follow on the same
3147
+ # paragraph, so a closer and a run of short sentences (short-run's
3148
+ # tell) are both left to their own rules.
3149
+ #
3150
+ # Read against RAID, the first draft fired on human prose at four
3151
+ # times its model rate, and every kind of hit is now a guard:
3152
+ #
3153
+ # A title or initial before the stop ("Mrs. Cadaver.", "John F.
3154
+ # Kennedy.", "St. Petersburg.") makes the proper name after it look
3155
+ # like a one-word sentence, so the stop the punch follows may not
3156
+ # close a capitalised word of one to four letters, or a lone lowercase
3157
+ # letter ("Swift v. Tyson"). The same guard on
3158
+ # the punch's own last word ("When Mr.", "Felix and Rev.") is why a
3159
+ # lone word must be five letters or more when capitalised; a lowercase
3160
+ # last word ("Not anymore.") is never an abbreviation.
3161
+ #
3162
+ # A short clause with a pronoun subject ("He agrees.", "She complies.",
3163
+ # "Both die.") is how a plot summary and a news brief move the story
3164
+ # along, and an imperative with a name or a noun subject ("Dawes
3165
+ # refuses.", "Set aside.") reads the same to a regex as a beat, so the
3166
+ # punch is two closed shapes: a negator (Not, No, Never, Nothing,
3167
+ # None) with up to two lowercase words after it, or one capitalised
3168
+ # word of five letters or more. A one-word imperative in procedural
3169
+ # prose ("Drain.", "Repeat.") still lands in the second shape, at about
3170
+ # one per 25,000 words of recipes, which the info severity allows for.
3171
+ pattern: /#{SENTENCE_OF_SIXTY_CHARACTERS_ENDING_IN_PUNCTUATION_AND_SPACE}\K
3172
+ (?<![A-Z]\.[ \t]|[A-Z][a-z]\.[ \t]|[A-Z][a-z]{2}\.[ \t]|[A-Z][a-z]{3}\.[ \t]|[ \t][a-z]\.[ \t]
3173
+ |[A-Z]\.[ \t]{2}|[A-Z][a-z]\.[ \t]{2}|[A-Z][a-z]{2}\.[ \t]{2}|[A-Z][a-z]{3}\.[ \t]{2}|[ \t][a-z]\.[ \t]{2})
3174
+ (?:(?:Not|No|Never|Nothing|None)(?:(?:[ \t]|\r?\n(?!\s*\n)[ \t]*)[a-z][A-Za-z'’-]*){0,2}
3175
+ |[A-Z][A-Za-z'’-]{4,})\.
3176
+ (?=[ \t]{1,2}[A-Z](?:[^.!?\n]|\r?\n(?!\s*\n)){40,})/x,
3177
+ message: "A verbless beat of three words or fewer between two long sentences is the AI punch.",
3178
+ suggestion: "Fold the beat into the sentence before or after it, or cut it.",
3179
+ examples_bad: [
3180
+ "The on-call engineer had been paged for the same cron job three nights running that week. Not anymore. The job now checks the lock file before it starts and exits quietly.",
3181
+ "The reviewers spent the whole afternoon arguing about whether the migration should run in one pass. Never again. The first half ran on Tuesday and the second half is still waiting on a sign-off.",
3182
+ "Every team we talked to had a wiki page describing the release process in loving detail. No exceptions. The page had not been touched since the person who wrote it left the company.",
3183
+ "The retry loop was supposed to give up after five attempts and page someone with the error. Simple. Instead it kept going all night because the counter reset on every restart."
3184
+ ],
3185
+ examples_ok: [
3186
+ # Paragraph-final: that is the closer rules' shape, not this one.
3187
+ "The reviewers spent the whole afternoon arguing about whether the migration should run in one pass. Not anymore.",
3188
+ # Followed by another short sentence: short-run's territory.
3189
+ "The reviewers spent the whole afternoon arguing about whether the migration should run in one pass. Not anymore. Nobody minded. The second half is still waiting on a sign-off from the platform team.",
3190
+ # A short clause with a pronoun subject is narrative, not a beat.
3191
+ "The reviewers spent the whole afternoon arguing about whether the migration should run in one pass. It didn't. The first half ran on Tuesday and the second half is still waiting on a sign-off.",
3192
+ "Every team we talked to had a wiki page describing the release process in loving detail. Nobody read it. The page had not been touched since the person who wrote it left the company.",
3193
+ # A title or initial with its complement on the far side of the stop.
3194
+ "The committee met for the last time on a wet Thursday in the back room of the library annex. Mr. Whitaker moved that the minutes be approved as read and the motion carried without a vote.",
3195
+ "The only survivor of the crash on the coast road that spring was the widow of the lighthouse keeper, Mrs. Cadaver. The inquest sat for two days in the town hall and returned no verdict at all.",
3196
+ "The freight line ran west from the river through three counties before it reached the yards. St. Louis was the end of the line for most of the crews and the start of it for the rest.",
3197
+ "The freight line ran west from the river through three counties before it reached the yards. Cyrus M. Jarrett owned the yards and every siding on the line from the river to the state line.",
3198
+ "The rule that a federal court sitting in diversity applies general common law was set out in Swift v. Tyson. The decision stood for nearly a century before the court overruled it in Erie.",
3199
+ # Digits are data, and a quotation mark is dialogue.
3200
+ "The freight line ran west from the river through three counties before it reached the yards. Fig. 2 shows the route as it stood in 1890 and the sidings that were added after the flood.",
3201
+ "The freight line ran west from the river through three counties before it reached the yards. \"Enough.\" The foreman waved the crew back toward the sheds and nobody argued with him.",
3202
+ # Four or more words is a sentence, not a beat.
3203
+ "The freight line ran west from the river through three counties before it reached the yards. The crews liked it. The foreman waved the crew back toward the sheds and nobody argued with him."
3204
+ ],
3205
+ rationale: "The beat withholds the verb and the object the long sentence set up and " \
3206
+ "hands the reader a pause instead. People write one on purpose; a draft " \
3207
+ "that keeps doing it is the tell, which is why it ships at info."
3208
+ ),
3082
3209
  Rule.new(
3083
3210
  id: "np-fragment-and",
3084
3211
  category: "cadence",