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.
@@ -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