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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +123 -1
- data/README.md +90 -5
- data/docs/SPEC.md +31 -6
- data/exe/sloplint +2 -1
- data/lib/sloplint/cli.rb +208 -40
- data/lib/sloplint/engine.rb +31 -4
- data/lib/sloplint/output.rb +9 -5
- data/lib/sloplint/rules.rb +127 -0
- data/lib/sloplint/split.rb +348 -0
- data/lib/sloplint/version.rb +1 -1
- metadata +2 -1
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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("
|
|
94
|
-
return
|
|
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("
|
|
108
|
-
return
|
|
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
|
-
|
|
116
|
-
|
|
117
|
-
out.puts(Output.format_json(
|
|
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(
|
|
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
|
-
|
|
141
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
340
|
+
catalog.select { |r| select.include?(r.id) || (select.include?(r.category) && (runs_by_default.call(r) || strict)) }
|
|
209
341
|
elsif strict
|
|
210
|
-
|
|
342
|
+
catalog
|
|
211
343
|
else
|
|
212
|
-
|
|
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
|
-
|
|
221
|
-
|
|
222
|
-
|
|
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
|
-
|
|
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
|
data/lib/sloplint/engine.rb
CHANGED
|
@@ -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
|
-
|
|
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}[#{
|
|
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(
|
|
132
|
+
text.gsub(MARKDOWN_NOISE) { |s| s.gsub(/[^\n]/, " ") }
|
|
106
133
|
end
|
|
107
134
|
end
|
|
108
135
|
end
|
data/lib/sloplint/output.rb
CHANGED
|
@@ -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
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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
|
-
|
|
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)
|
data/lib/sloplint/rules.rb
CHANGED
|
@@ -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",
|