sloplint 0.1.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 +7 -0
- data/CHANGELOG.md +42 -0
- data/LICENSE +21 -0
- data/README.md +179 -0
- data/bin/sloplint +5 -0
- data/docs/SPEC.md +308 -0
- data/lib/sloplint/cli.rb +212 -0
- data/lib/sloplint/engine.rb +78 -0
- data/lib/sloplint/output.rb +37 -0
- data/lib/sloplint/rules.rb +373 -0
- data/lib/sloplint/version.rb +5 -0
- data/lib/sloplint.rb +7 -0
- metadata +87 -0
data/lib/sloplint/cli.rb
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "optparse"
|
|
4
|
+
require "json"
|
|
5
|
+
require_relative "version"
|
|
6
|
+
require_relative "rules"
|
|
7
|
+
require_relative "engine"
|
|
8
|
+
require_relative "output"
|
|
9
|
+
|
|
10
|
+
module Sloplint
|
|
11
|
+
# Command-line shell: optparse, subcommands, exit codes. See docs/SPEC.md.
|
|
12
|
+
#
|
|
13
|
+
# Exit codes: 0 ran/no notes, 1 ran/notes found, 2 bad arguments.
|
|
14
|
+
module CLI
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def run(argv, out: $stdout, err: $stderr, stdin: $stdin)
|
|
18
|
+
opts = { format: "full" }
|
|
19
|
+
parser = global_parser(opts, out:)
|
|
20
|
+
# Split global options from the subcommand and its args.
|
|
21
|
+
parser.order!(argv)
|
|
22
|
+
return 0 if opts[:help_shown] || opts[:version_shown]
|
|
23
|
+
|
|
24
|
+
command = argv.shift
|
|
25
|
+
|
|
26
|
+
# Bare invocation with piped stdin behaves as `check -`.
|
|
27
|
+
command ||= (stdin.tty? ? "help" : "check")
|
|
28
|
+
|
|
29
|
+
case command
|
|
30
|
+
when "check" then cmd_check(argv, opts, out:, err:, stdin:)
|
|
31
|
+
when "rules" then cmd_rules(argv, out:)
|
|
32
|
+
when "explain" then cmd_explain(argv, out:, err:)
|
|
33
|
+
when "version" then out.puts(VERSION); 0
|
|
34
|
+
when "help" then out.puts(parser.help); 0
|
|
35
|
+
else
|
|
36
|
+
err.puts("sloplint: unknown command #{command.inspect}")
|
|
37
|
+
err.puts(parser.help)
|
|
38
|
+
2
|
|
39
|
+
end
|
|
40
|
+
rescue OptionParser::ParseError => e
|
|
41
|
+
err.puts("sloplint: #{e.message}")
|
|
42
|
+
2
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
# ── check ───────────────────────────────────────────────────────────────
|
|
46
|
+
def cmd_check(argv, opts, out:, err:, stdin:)
|
|
47
|
+
markdown = false
|
|
48
|
+
select = nil
|
|
49
|
+
ignore = nil
|
|
50
|
+
p = OptionParser.new do |o|
|
|
51
|
+
o.banner = "usage: sloplint check [options] [paths...] (\"-\" or no paths = stdin)"
|
|
52
|
+
o.on("-o", "--output-format FORMAT", %w[full json],
|
|
53
|
+
"Output format: 'full' or 'json' (may also be given before the command).") { |v| opts[:format] = v }
|
|
54
|
+
o.on("--markdown", "Skip fenced/inline code spans and URLs before scanning.") { markdown = true }
|
|
55
|
+
o.on("--select IDS", "Only run these rules (comma-separated rule ids or category names).") { |v| select = v.split(",").map(&:strip) }
|
|
56
|
+
o.on("--ignore IDS", "Skip these rules (comma-separated rule ids or category names).") { |v| ignore = v.split(",").map(&:strip) }
|
|
57
|
+
end
|
|
58
|
+
p.order!(argv)
|
|
59
|
+
|
|
60
|
+
unknown = unknown_rule_refs(select) + unknown_rule_refs(ignore)
|
|
61
|
+
unless unknown.empty?
|
|
62
|
+
err.puts("sloplint: unknown rule or category: #{unknown.join(", ")}")
|
|
63
|
+
err.puts("run `sloplint rules` to list them.")
|
|
64
|
+
return 2
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
rules = select_rules(select, ignore)
|
|
68
|
+
paths = argv.empty? ? ["-"] : argv
|
|
69
|
+
by_path = paths.reject { |x| x == "-" }.size > 1
|
|
70
|
+
|
|
71
|
+
all_notes = []
|
|
72
|
+
paths.each do |path|
|
|
73
|
+
text =
|
|
74
|
+
if path == "-"
|
|
75
|
+
stdin.read
|
|
76
|
+
else
|
|
77
|
+
unless File.file?(path)
|
|
78
|
+
err.puts("sloplint: no such file: #{path}")
|
|
79
|
+
return 2
|
|
80
|
+
end
|
|
81
|
+
File.read(path)
|
|
82
|
+
end
|
|
83
|
+
label = path == "-" ? "-" : path
|
|
84
|
+
all_notes.concat(Engine.scan(text, rules:, markdown:, path: label))
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
case opts[:format]
|
|
88
|
+
when "json"
|
|
89
|
+
out.puts(Output.format_json(all_notes, by_path:))
|
|
90
|
+
else
|
|
91
|
+
text = Output.format_human(all_notes)
|
|
92
|
+
out.puts(text) unless text.empty?
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
all_notes.empty? ? 0 : 1
|
|
96
|
+
rescue ArgumentError => e
|
|
97
|
+
err.puts("sloplint: invalid input: #{e.message}")
|
|
98
|
+
2
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
# ── rules ───────────────────────────────────────────────────────────────
|
|
102
|
+
def cmd_rules(argv, out:)
|
|
103
|
+
as_json = false
|
|
104
|
+
OptionParser.new do |o|
|
|
105
|
+
o.banner = "usage: sloplint rules [--json]"
|
|
106
|
+
o.on("--json", "Emit the catalog as JSON for machine enumeration.") { as_json = true }
|
|
107
|
+
end.order!(argv)
|
|
108
|
+
|
|
109
|
+
if as_json
|
|
110
|
+
payload = RULES.map do |r|
|
|
111
|
+
{ id: r.id, category: r.category, severity: r.severity,
|
|
112
|
+
message: r.message, suggestion: r.suggestion, default_on: r.default_on }
|
|
113
|
+
end
|
|
114
|
+
out.puts(JSON.pretty_generate(payload))
|
|
115
|
+
else
|
|
116
|
+
RULES.each do |r|
|
|
117
|
+
off = r.default_on ? "" : " [off by default]"
|
|
118
|
+
out.puts("#{r.id.ljust(24)} #{r.category.ljust(14)} #{r.severity.ljust(8)} #{r.message}#{off}")
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
0
|
|
122
|
+
end
|
|
123
|
+
|
|
124
|
+
# ── explain ID ────────────────────────────────────────────────────────
|
|
125
|
+
def cmd_explain(argv, out:, err:)
|
|
126
|
+
id = argv.shift
|
|
127
|
+
unless id
|
|
128
|
+
err.puts("usage: sloplint explain RULE_ID")
|
|
129
|
+
return 2
|
|
130
|
+
end
|
|
131
|
+
rule = RULES.find { |r| r.id == id }
|
|
132
|
+
unless rule
|
|
133
|
+
err.puts("sloplint: no such rule: #{id}")
|
|
134
|
+
err.puts("run `sloplint rules` to list them.")
|
|
135
|
+
return 2
|
|
136
|
+
end
|
|
137
|
+
out.puts(<<~TXT)
|
|
138
|
+
#{rule.id} (#{rule.category}, #{rule.severity}#{rule.default_on ? "" : ", off by default"})
|
|
139
|
+
|
|
140
|
+
#{rule.message}
|
|
141
|
+
|
|
142
|
+
Why: #{rule.rationale}
|
|
143
|
+
Fix: #{rule.suggestion}
|
|
144
|
+
|
|
145
|
+
Flags: #{rule.examples_bad.join("\n ")}
|
|
146
|
+
Does not: #{rule.examples_ok.join("\n ")}
|
|
147
|
+
TXT
|
|
148
|
+
0
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# ── helpers ─────────────────────────────────────────────────────────────
|
|
152
|
+
# Ids/categories in refs that match no rule in the catalog. nil (no --select
|
|
153
|
+
# or --ignore given) passes through as no unknowns.
|
|
154
|
+
def unknown_rule_refs(refs)
|
|
155
|
+
return [] unless refs
|
|
156
|
+
|
|
157
|
+
known = RULES.flat_map { |r| [r.id, r.category] }.uniq
|
|
158
|
+
refs - known
|
|
159
|
+
end
|
|
160
|
+
|
|
161
|
+
# --select/--ignore accept rule ids or category names. Default set excludes
|
|
162
|
+
# default_on:false rules unless they are explicitly selected.
|
|
163
|
+
def select_rules(select, ignore)
|
|
164
|
+
rules = if select
|
|
165
|
+
RULES.select { |r| select.include?(r.id) || select.include?(r.category) }
|
|
166
|
+
else
|
|
167
|
+
RULES.select(&:default_on)
|
|
168
|
+
end
|
|
169
|
+
if ignore
|
|
170
|
+
rules = rules.reject { |r| ignore.include?(r.id) || ignore.include?(r.category) }
|
|
171
|
+
end
|
|
172
|
+
rules
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
def global_parser(opts, out:)
|
|
176
|
+
OptionParser.new do |o|
|
|
177
|
+
o.banner = <<~BANNER
|
|
178
|
+
sloplint — flag the rhetorical tics and puffery that mark AI-generated prose.
|
|
179
|
+
|
|
180
|
+
# Recommended for agents:
|
|
181
|
+
cat FILE | sloplint check --markdown -o json -
|
|
182
|
+
# exit 0 = clean, 1 = notes found, >1 = error
|
|
183
|
+
# each note: {path,line,column,severity,rule,category,message,excerpt,suggestion}
|
|
184
|
+
|
|
185
|
+
usage: sloplint [-o full|json] <command> [args]
|
|
186
|
+
|
|
187
|
+
commands:
|
|
188
|
+
check scan paths (or stdin) for AI-slop tells and report notes [default]
|
|
189
|
+
rules list the rule catalog (add --json for the machine-readable form)
|
|
190
|
+
explain ID print one rule's message, rationale, and a bad/ok example
|
|
191
|
+
version print the sloplint version
|
|
192
|
+
|
|
193
|
+
global options:
|
|
194
|
+
BANNER
|
|
195
|
+
o.on("-o", "--output-format FORMAT", %w[full json],
|
|
196
|
+
"Output format: 'full' (human-readable text) or 'json' (default: full).") do |v|
|
|
197
|
+
opts[:format] = v
|
|
198
|
+
end
|
|
199
|
+
o.on("-h", "--help", "Show this help, including the copy-paste agent recipe above.") do
|
|
200
|
+
out.puts(o.help)
|
|
201
|
+
opts[:help_shown] = true
|
|
202
|
+
end
|
|
203
|
+
o.on("-v", "--version", "Print the sloplint version.") do
|
|
204
|
+
out.puts(VERSION)
|
|
205
|
+
opts[:version_shown] = true
|
|
206
|
+
end
|
|
207
|
+
o.separator ""
|
|
208
|
+
o.separator "See `sloplint explain <id>` for any rule, or docs/SPEC.md for the JSON contract."
|
|
209
|
+
end
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
end
|
|
@@ -0,0 +1,78 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rules"
|
|
4
|
+
|
|
5
|
+
module Sloplint
|
|
6
|
+
# One match = one Note. See docs/SPEC.md "Note".
|
|
7
|
+
Note = Data.define(
|
|
8
|
+
:path, :line, :column, :severity, :rule, :category,
|
|
9
|
+
:message, :excerpt, :count, :suggestion
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
module Engine
|
|
13
|
+
module_function
|
|
14
|
+
|
|
15
|
+
# text: the source. rules: which Rule objects to run. markdown: blank code/URLs first.
|
|
16
|
+
# path: label carried into each Note (e.g. filename or "-" for stdin).
|
|
17
|
+
def scan(text, rules: RULES, markdown: false, path: "-")
|
|
18
|
+
text = blank_markdown(text) if markdown
|
|
19
|
+
line_starts = line_starts_for(text)
|
|
20
|
+
notes = []
|
|
21
|
+
rules.each do |rule|
|
|
22
|
+
text.to_enum(:scan, rule.pattern).each do
|
|
23
|
+
m = Regexp.last_match
|
|
24
|
+
matched = m[0]
|
|
25
|
+
next if rule.skip.any? { |re| matched.match?(re) }
|
|
26
|
+
|
|
27
|
+
count = rule.count_group ? matched.scan(rule.count_group).size : nil
|
|
28
|
+
line, column = line_col(text, m.begin(0), line_starts:)
|
|
29
|
+
message = count ? rule.message % { count: count } : rule.message
|
|
30
|
+
notes << Note.new(
|
|
31
|
+
path: path, line: line, column: column,
|
|
32
|
+
severity: rule.severity, rule: rule.id, category: rule.category,
|
|
33
|
+
message: message, excerpt: matched.gsub(/\s+/, " ").strip,
|
|
34
|
+
count: count, suggestion: rule.suggestion
|
|
35
|
+
)
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
notes.sort_by { |n| [n.line, n.column] }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
# 1-indexed line and column for a char offset into text. Binary-searches a
|
|
42
|
+
# precomputed line_starts table (see line_starts_for) so a scan with many
|
|
43
|
+
# notes doesn't re-walk the prefix from offset 0 for every single one --
|
|
44
|
+
# the previous version did text[0, offset] per note, which is O(n) per
|
|
45
|
+
# call and O(n * notes) overall, quadratic on a large file with many hits.
|
|
46
|
+
# line_starts is optional so this stays callable standalone.
|
|
47
|
+
def line_col(text, offset, line_starts: nil)
|
|
48
|
+
line_starts ||= line_starts_for(text)
|
|
49
|
+
idx = (line_starts.bsearch_index { |s| s > offset } || line_starts.length) - 1
|
|
50
|
+
[idx + 1, offset - line_starts[idx] + 1]
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Char offset where each line begins, index 0 = line 1. Computed once per
|
|
54
|
+
# scan and shared across every note instead of recomputed per note.
|
|
55
|
+
#
|
|
56
|
+
# Built via each_line + line.length, not repeated String#index(pat, pos)
|
|
57
|
+
# calls -- on non-ASCII-only text (e.g. curly quotes, em dashes), index
|
|
58
|
+
# with a start position is not O(1)-amortized per call in CRuby, and a
|
|
59
|
+
# few thousand newlines turned this quadratic: 7.4s on a 1.2MB UTF-8
|
|
60
|
+
# file that each_line does in 0.004s.
|
|
61
|
+
def line_starts_for(text)
|
|
62
|
+
starts = [0]
|
|
63
|
+
text.each_line { |line| starts << starts.last + line.length }
|
|
64
|
+
starts.pop unless text.empty? || text.end_with?("\n")
|
|
65
|
+
starts
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# Replace fenced code, inline code, and URLs with same-length whitespace so
|
|
69
|
+
# line/column stay correct. Newlines are preserved.
|
|
70
|
+
def blank_markdown(text)
|
|
71
|
+
blank = lambda { |s| s.gsub(/[^\n]/, " ") }
|
|
72
|
+
text
|
|
73
|
+
.gsub(/```.*?```/m) { |s| blank.call(s) } # fenced code
|
|
74
|
+
.gsub(/`[^`\n]*`/) { |s| blank.call(s) } # inline code
|
|
75
|
+
.gsub(%r{https?://\S+}) { |s| blank.call(s) } # bare URLs
|
|
76
|
+
end
|
|
77
|
+
end
|
|
78
|
+
end
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Sloplint
|
|
6
|
+
module Output
|
|
7
|
+
module_function
|
|
8
|
+
|
|
9
|
+
# proselint-style "full" text: one note per line, with excerpt + suggestion.
|
|
10
|
+
def format_human(notes)
|
|
11
|
+
return "" if notes.empty?
|
|
12
|
+
|
|
13
|
+
notes.map do |n|
|
|
14
|
+
head = "#{n.path}:#{n.line}:#{n.column}: #{n.severity} #{n.rule} #{n.message}"
|
|
15
|
+
excerpt = " excerpt: #{n.excerpt}"
|
|
16
|
+
fix = " fix: #{n.suggestion}"
|
|
17
|
+
[head, excerpt, fix].join("\n")
|
|
18
|
+
end.join("\n\n")
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
# JSON: an array of notes, or an object keyed by path when >1 file was scanned.
|
|
22
|
+
def format_json(notes, by_path: false)
|
|
23
|
+
if by_path
|
|
24
|
+
grouped = notes.group_by(&:path).transform_values { |ns| ns.map { |n| note_hash(n) } }
|
|
25
|
+
JSON.pretty_generate(grouped)
|
|
26
|
+
else
|
|
27
|
+
JSON.pretty_generate(notes.map { |n| note_hash(n) })
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def note_hash(note)
|
|
32
|
+
h = note.to_h
|
|
33
|
+
h.delete(:count) if h[:count].nil?
|
|
34
|
+
h
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|