judgelint 0.1.0-aarch64-linux
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 +24 -0
- data/LICENSE +21 -0
- data/README.md +225 -0
- data/exe/judgelint +14 -0
- data/lib/judgelint/cache.rb +53 -0
- data/lib/judgelint/changes.rb +87 -0
- data/lib/judgelint/cli.rb +240 -0
- data/lib/judgelint/client.rb +182 -0
- data/lib/judgelint/config.rb +162 -0
- data/lib/judgelint/errors.rb +14 -0
- data/lib/judgelint/glob.rb +31 -0
- data/lib/judgelint/pool.rb +41 -0
- data/lib/judgelint/protocol.rb +88 -0
- data/lib/judgelint/providers.rb +78 -0
- data/lib/judgelint/question.rb +100 -0
- data/lib/judgelint/reporter.rb +147 -0
- data/lib/judgelint/rule.rb +147 -0
- data/lib/judgelint/runner.rb +274 -0
- data/lib/judgelint/scaffold.rb +67 -0
- data/lib/judgelint/source.rb +98 -0
- data/lib/judgelint/spec.rb +189 -0
- data/lib/judgelint/usage.rb +53 -0
- data/lib/judgelint/verdict.rb +61 -0
- data/lib/judgelint/version.rb +5 -0
- data/lib/judgelint.rb +57 -0
- data/lib/rubygems_plugin.rb +16 -0
- data/libexec/judgelint +0 -0
- metadata +76 -0
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judgelint
|
|
4
|
+
# Files written by `judgelint init`.
|
|
5
|
+
module Scaffold
|
|
6
|
+
CONFIG = <<~YAML
|
|
7
|
+
# judgelint settings. See https://github.com/jpcamara/judgelint#configuration
|
|
8
|
+
models: [typesafe] # `judgelint providers` lists the others; list two to average them
|
|
9
|
+
# threshold: 0.7
|
|
10
|
+
# ignore: ["vendor/**"]
|
|
11
|
+
# max_cost_usd: 1.00
|
|
12
|
+
YAML
|
|
13
|
+
|
|
14
|
+
# A spec: each bullet is a convention judgelint checks. A README with
|
|
15
|
+
# paths: frontmatter works the same way, next to the code it governs.
|
|
16
|
+
SPEC = <<~MARKDOWN
|
|
17
|
+
---
|
|
18
|
+
paths: ["**/*.rb"]
|
|
19
|
+
excludes: ["test/**", "spec/**"]
|
|
20
|
+
---
|
|
21
|
+
# Ruby conventions
|
|
22
|
+
|
|
23
|
+
- **No silent rescues.** A rescue logs the error, re-raises it, or turns it
|
|
24
|
+
into a result the caller sees. Swallowed exceptions hide bugs until they
|
|
25
|
+
cost us data.
|
|
26
|
+
- **Error messages say what to do next.** A raised or returned error names
|
|
27
|
+
what was wrong and what would work instead.
|
|
28
|
+
MARKDOWN
|
|
29
|
+
|
|
30
|
+
PASS_EXAMPLE = <<~RUBY
|
|
31
|
+
def fetch_profile(id)
|
|
32
|
+
client.profile(id)
|
|
33
|
+
rescue Client::Timeout => e
|
|
34
|
+
logger.warn("profile \#{id} timed out: \#{e.message}")
|
|
35
|
+
raise
|
|
36
|
+
end
|
|
37
|
+
RUBY
|
|
38
|
+
|
|
39
|
+
FAIL_EXAMPLE = <<~RUBY
|
|
40
|
+
def fetch_profile(id)
|
|
41
|
+
client.profile(id)
|
|
42
|
+
rescue StandardError
|
|
43
|
+
nil
|
|
44
|
+
end
|
|
45
|
+
RUBY
|
|
46
|
+
|
|
47
|
+
def self.write(root, out)
|
|
48
|
+
examples = ".judgelint/examples/ruby/no-silent-rescues"
|
|
49
|
+
files = {
|
|
50
|
+
Config::FILE => CONFIG,
|
|
51
|
+
".judgelint/rules/ruby.md" => SPEC,
|
|
52
|
+
File.join(examples, "pass", "logs_and_reraises.rb") => PASS_EXAMPLE,
|
|
53
|
+
File.join(examples, "fail", "returns_nil.rb") => FAIL_EXAMPLE
|
|
54
|
+
}
|
|
55
|
+
files.each do |name, content|
|
|
56
|
+
path = root == "." ? name : File.join(root, name)
|
|
57
|
+
if File.exist?(path)
|
|
58
|
+
out.puts "exists #{path}"
|
|
59
|
+
else
|
|
60
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
61
|
+
File.write(path, content)
|
|
62
|
+
out.puts "create #{path}"
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judgelint
|
|
4
|
+
# What the model is shown: one file, or every file in a directory, with each
|
|
5
|
+
# line numbered so a question can point at a range, followed by any context
|
|
6
|
+
# files the rule names, which are there to read and never judged.
|
|
7
|
+
#
|
|
8
|
+
# Inline suppressions turn a rule off where a person has decided the code is
|
|
9
|
+
# fine, and say why: a comment (#, //, --, /*, <!--, or ;) starting with
|
|
10
|
+
# `judgelint:disable <rule ids> -- <reason>` covers the window it falls in,
|
|
11
|
+
# and `judgelint:disable-file <rule ids> -- <reason>` covers the whole file.
|
|
12
|
+
# In a directory, either one covers the directory.
|
|
13
|
+
class Source
|
|
14
|
+
SUPPRESSION = %r{(?:#|//|--|/\*|<!--|;)\s*judgelint:(disable-file|disable)\b(.*)\z}
|
|
15
|
+
REASONED = %r{\A\s+([A-Za-z0-9_./, -]+?)\s+--\s+\S}
|
|
16
|
+
|
|
17
|
+
attr_reader :path, :shown_as, :line_count
|
|
18
|
+
|
|
19
|
+
def self.directory(dir, shown_as: dir, context: [])
|
|
20
|
+
files = Glob.expand([File.join(dir, "**", "*")])
|
|
21
|
+
new(dir, shown_as: shown_as, files: files, context: context, directory: true)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def initialize(path, shown_as: path, files: [path], context: [], directory: false)
|
|
25
|
+
@path = path
|
|
26
|
+
@shown_as = shown_as
|
|
27
|
+
@directory = directory
|
|
28
|
+
@files = files.map { |file| [file, File.read(file).lines] }
|
|
29
|
+
@context = context
|
|
30
|
+
@line_count = @files.sum { |_file, lines| lines.size }
|
|
31
|
+
@disabled = {}
|
|
32
|
+
@disabled_file = []
|
|
33
|
+
@files.each { |file, lines| find_suppressions(file, lines) }
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def directory?
|
|
37
|
+
@directory
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def state
|
|
41
|
+
body =
|
|
42
|
+
if directory?
|
|
43
|
+
sections = @files.map { |file, lines| "Path: #{shown(file)}\n\n#{numbered(lines)}" }
|
|
44
|
+
"Directory: #{shown_as}\n\n#{sections.join("\n")}"
|
|
45
|
+
else
|
|
46
|
+
"Path: #{shown_as}\n\n#{numbered(@files.first[1])}"
|
|
47
|
+
end
|
|
48
|
+
return body if @context.empty?
|
|
49
|
+
|
|
50
|
+
references = @context.map { |file| "Path: #{file}\n\n#{File.read(file)}" }
|
|
51
|
+
"#{body}\nContext, for reference only (not under review):\n\n#{references.join("\n")}"
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
# [first, last] ranges of +size+ lines that cover the file.
|
|
55
|
+
def windows(size)
|
|
56
|
+
(1..line_count).step(size).map { |first| [first, [first + size - 1, line_count].min] }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def suppressed?(rule_id, first, last)
|
|
60
|
+
return true if @disabled_file.include?(rule_id)
|
|
61
|
+
|
|
62
|
+
lines = @disabled[rule_id] || []
|
|
63
|
+
directory? ? !lines.empty? : lines.any? { |line| line.between?(first, last) }
|
|
64
|
+
end
|
|
65
|
+
|
|
66
|
+
def suppressed_ids
|
|
67
|
+
(@disabled.keys + @disabled_file).uniq
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
private
|
|
71
|
+
|
|
72
|
+
def numbered(lines)
|
|
73
|
+
lines.each_with_index.map { |line, index| "#{index + 1}: #{line.chomp}\n" }.join
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
# A file's path inside the directory, as the model sees it.
|
|
77
|
+
def shown(file)
|
|
78
|
+
"#{shown_as}/#{file.delete_prefix("#{path}/")}"
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def find_suppressions(file, lines)
|
|
82
|
+
lines.each_with_index do |line, index|
|
|
83
|
+
match = SUPPRESSION.match(line.chomp)
|
|
84
|
+
next unless match
|
|
85
|
+
|
|
86
|
+
reasoned = REASONED.match(match[2])
|
|
87
|
+
raise ConfigError, "#{file}:#{index + 1}: judgelint:#{match[1]} needs rule ids and a reason after --" unless reasoned
|
|
88
|
+
|
|
89
|
+
ids = reasoned[1].split(/[\s,]+/).reject(&:empty?)
|
|
90
|
+
if match[1] == "disable-file"
|
|
91
|
+
@disabled_file.concat(ids)
|
|
92
|
+
else
|
|
93
|
+
ids.each { |id| (@disabled[id] ||= []) << index + 1 }
|
|
94
|
+
end
|
|
95
|
+
end
|
|
96
|
+
end
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judgelint
|
|
4
|
+
# Finds rules where a team already writes its conventions: a README (or any
|
|
5
|
+
# *.judgelint.md) whose frontmatter says which files it governs, with one
|
|
6
|
+
# convention per top-level bullet, as praxis reads them.
|
|
7
|
+
#
|
|
8
|
+
# ---
|
|
9
|
+
# paths: ["src/services/*.ts"]
|
|
10
|
+
# excludes: ["src/services/legacy-import.ts"]
|
|
11
|
+
# ---
|
|
12
|
+
# # Service conventions
|
|
13
|
+
#
|
|
14
|
+
# - **Domain failures are values, never exceptions.** A service returns
|
|
15
|
+
# { ok: false, error } for anything a caller could plausibly cause.
|
|
16
|
+
# - **No I/O except through the injected Store.** No timers, no fetch.
|
|
17
|
+
#
|
|
18
|
+
# Each bullet becomes a rule, with an id from its lead words, such as
|
|
19
|
+
# src/services/domain-failures-are-values-never-exceptions. The model sees
|
|
20
|
+
# the spec's prose alongside the file, and is asked whether the code breaks
|
|
21
|
+
# the bullet and whether it follows it. Indented `violation:` and
|
|
22
|
+
# `compliant:` lines under a bullet say what each looks like:
|
|
23
|
+
#
|
|
24
|
+
# - **No I/O except through the injected Store.**
|
|
25
|
+
# violation: calls fetch, console, or a timer directly
|
|
26
|
+
# compliant: reads and writes only through the Store it's given
|
|
27
|
+
#
|
|
28
|
+
# Files in .judgelint/rules/ work the same way, and one there with a
|
|
29
|
+
# question: in its frontmatter is a single rule asked exactly that way.
|
|
30
|
+
module Spec
|
|
31
|
+
PATTERNS = ["**/README.md", "**/*.judgelint.md"].freeze
|
|
32
|
+
KEYS = %w[paths excludes context scope cohort type severity threshold].freeze
|
|
33
|
+
COHORTS = { "by_file" => "file", "by_directory" => "directory" }.freeze
|
|
34
|
+
BULLET = /\A(?:[-*+]|\d+[.)])\s+(\S.*)\z/
|
|
35
|
+
CRITERION = /\A(violation|compliant):\s*(\S.*)\z/i
|
|
36
|
+
LEAD = /\A\*\*(.+?)\*\*/
|
|
37
|
+
|
|
38
|
+
# +readmes+ false looks in +rules_dir+ alone.
|
|
39
|
+
def self.discover(rules_dir:, default_threshold:, ignore: [], readmes: true)
|
|
40
|
+
# path.dup: Spinel can't yet share these Strings into the rules it builds.
|
|
41
|
+
found = Dir.glob(File.join(rules_dir, "*.md")).sort.flat_map do |path|
|
|
42
|
+
load(path.dup, default_threshold: default_threshold, required: true)
|
|
43
|
+
end
|
|
44
|
+
specs = readmes ? Glob.expand(PATTERNS) : []
|
|
45
|
+
specs = specs.reject { |path| path.start_with?("#{rules_dir}/") || Glob.match_any?(ignore, path) }
|
|
46
|
+
found += specs.flat_map { |path| load(path.dup, default_threshold: default_threshold, required: false) }
|
|
47
|
+
raise ConfigError, "No rules: add a README with paths: frontmatter, or run `judgelint init`" if found.empty?
|
|
48
|
+
|
|
49
|
+
found.group_by(&:question_key).each_value do |same|
|
|
50
|
+
raise ConfigError, "Rule ids collide: #{same.map(&:id).join(', ')}" if same.size > 1
|
|
51
|
+
end
|
|
52
|
+
found
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# A README without frontmatter, or without paths:, isn't a spec.
|
|
56
|
+
def self.load(path, default_threshold:, required:)
|
|
57
|
+
match = Rule::FRONTMATTER.match(File.read(path))
|
|
58
|
+
unless match
|
|
59
|
+
raise ConfigError, "#{path}: rules start with a --- frontmatter block" if required
|
|
60
|
+
|
|
61
|
+
return []
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
data = Judgelint.parse_yaml(match[1], path)
|
|
65
|
+
body = match[2]
|
|
66
|
+
return [Rule.new(data, source: path, body: body.strip, default_threshold: default_threshold)] if data.key?("question")
|
|
67
|
+
return [] unless required || data.key?("paths")
|
|
68
|
+
|
|
69
|
+
unknown = data.keys - KEYS
|
|
70
|
+
raise ConfigError, "#{path}: unknown keys #{unknown.join(', ')}" unless unknown.empty?
|
|
71
|
+
|
|
72
|
+
shared = shared_settings(path, data)
|
|
73
|
+
found = bullets(body)
|
|
74
|
+
raise ConfigError, "#{path}: a rule needs a question, or bullets to turn into rules" if found.empty? && required
|
|
75
|
+
|
|
76
|
+
prose = body.strip
|
|
77
|
+
ids = []
|
|
78
|
+
found.map do |text, criteria|
|
|
79
|
+
id = unique("#{name(path)}/#{slug(text)}", ids)
|
|
80
|
+
rule = shared.merge("id" => id, "convention" => plain(text), "message" => lead(text))
|
|
81
|
+
rule["yes_means"] = plain(criteria["violation"]) if criteria["violation"]
|
|
82
|
+
rule["no_means"] = plain(criteria["compliant"]) if criteria["compliant"]
|
|
83
|
+
rule["severity"] = severity(text) unless data.key?("severity")
|
|
84
|
+
Rule.new(rule, source: path, default_threshold: default_threshold, prose: prose,
|
|
85
|
+
examples_dir: File.join(".judgelint", "examples", id))
|
|
86
|
+
end
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def self.shared_settings(path, data)
|
|
90
|
+
settings = {}
|
|
91
|
+
%w[paths excludes context severity threshold].each { |key| settings[key] = data[key] if data.key?(key) }
|
|
92
|
+
if data.key?("cohort")
|
|
93
|
+
cohort = data["cohort"].to_s
|
|
94
|
+
raise ConfigError, "#{path}: cohort is by_file or by_directory, not #{cohort}" unless COHORTS.key?(cohort)
|
|
95
|
+
|
|
96
|
+
settings["scope"] = COHORTS.fetch(cohort)
|
|
97
|
+
end
|
|
98
|
+
settings["scope"] = data["scope"] if data.key?("scope")
|
|
99
|
+
settings
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
# The top-level bullets of the body, outside code fences, as [text,
|
|
103
|
+
# criteria]: the text with its continuation lines and nested bullets, and
|
|
104
|
+
# any indented violation: or compliant: lines.
|
|
105
|
+
def self.bullets(body)
|
|
106
|
+
found = []
|
|
107
|
+
current = nil
|
|
108
|
+
criteria = {}
|
|
109
|
+
criterion = nil
|
|
110
|
+
fenced = false
|
|
111
|
+
body.each_line do |raw|
|
|
112
|
+
line = raw.chomp
|
|
113
|
+
if line.lstrip.start_with?("```")
|
|
114
|
+
fenced = !fenced
|
|
115
|
+
next
|
|
116
|
+
end
|
|
117
|
+
next if fenced
|
|
118
|
+
|
|
119
|
+
bullet = BULLET.match(line)
|
|
120
|
+
if bullet
|
|
121
|
+
found << [current, criteria] if current
|
|
122
|
+
current = bullet[1]
|
|
123
|
+
criteria = {}
|
|
124
|
+
criterion = nil
|
|
125
|
+
elsif current && line.start_with?(" ", "\t") && !line.strip.empty?
|
|
126
|
+
nested = BULLET.match(line.strip)
|
|
127
|
+
text = nested ? nested[1] : line.strip
|
|
128
|
+
named = CRITERION.match(text)
|
|
129
|
+
if named
|
|
130
|
+
criterion = named[1].downcase
|
|
131
|
+
criteria[criterion] = named[2]
|
|
132
|
+
elsif criterion
|
|
133
|
+
criteria[criterion] = "#{criteria[criterion]} #{text}"
|
|
134
|
+
else
|
|
135
|
+
current = "#{current} #{text}"
|
|
136
|
+
end
|
|
137
|
+
elsif current && !line.strip.empty?
|
|
138
|
+
found << [current, criteria]
|
|
139
|
+
current = nil
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
found << [current, criteria] if current
|
|
143
|
+
found
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
# The bullet with its markdown emphasis removed: what the model is asked about.
|
|
147
|
+
def self.plain(text)
|
|
148
|
+
text.gsub("**", "").gsub(/\s+/, " ").strip
|
|
149
|
+
end
|
|
150
|
+
|
|
151
|
+
# The bold lead, or the first sentence: what judgelint prints for a finding.
|
|
152
|
+
def self.lead(text)
|
|
153
|
+
bold = LEAD.match(text)
|
|
154
|
+
sentence = bold ? bold[1] : plain(text)
|
|
155
|
+
sentence = sentence[0, sentence.index(/[.!?:](\s|\z)/) || sentence.size]
|
|
156
|
+
"#{sentence.strip.sub(/[.!?:]\z/, '')}."
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def self.slug(text)
|
|
160
|
+
words = lead(text).downcase.scan(/[a-z0-9]+/).first(6)
|
|
161
|
+
words.empty? ? "rule" : words.join("-")
|
|
162
|
+
end
|
|
163
|
+
|
|
164
|
+
def self.unique(id, taken)
|
|
165
|
+
candidate = id
|
|
166
|
+
number = 1
|
|
167
|
+
while taken.include?(candidate)
|
|
168
|
+
number += 1
|
|
169
|
+
candidate = "#{id}-#{number}"
|
|
170
|
+
end
|
|
171
|
+
taken << candidate
|
|
172
|
+
candidate
|
|
173
|
+
end
|
|
174
|
+
|
|
175
|
+
# Spec names: a README's directory, a *.judgelint.md's stem, or a rules file's name.
|
|
176
|
+
def self.name(path)
|
|
177
|
+
dir = File.dirname(path)
|
|
178
|
+
return(dir == "." ? "root" : dir) if File.basename(path) == "README.md"
|
|
179
|
+
|
|
180
|
+
File.basename(path).delete_suffix(".md").delete_suffix(".judgelint")
|
|
181
|
+
end
|
|
182
|
+
|
|
183
|
+
# Praxis's reading: "should" and "prefer" are advice, anything else is a rule.
|
|
184
|
+
def self.severity(text)
|
|
185
|
+
advisory = /\b(should|prefer|ideally|consider)\b/i.match?(text)
|
|
186
|
+
advisory && !/\b(must|never|always)\b/i.match?(text) ? "warning" : "error"
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judgelint
|
|
4
|
+
# Requests and input tokens across a run, and what they cost. Decision
|
|
5
|
+
# models bill input only, so input tokens are the cost. Shared by every
|
|
6
|
+
# model's client in a run.
|
|
7
|
+
class Usage
|
|
8
|
+
attr_reader :requests, :input_tokens
|
|
9
|
+
|
|
10
|
+
def initialize
|
|
11
|
+
@requests = 0
|
|
12
|
+
@input_tokens = 0
|
|
13
|
+
@cost = 0.0
|
|
14
|
+
@unpriced = false
|
|
15
|
+
@lock = Mutex.new
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
# OpenRouter reports each request's cost; for the rest it's tokens x the
|
|
19
|
+
# model's price. A request with neither makes the run's cost unknown.
|
|
20
|
+
def record(data, price_per_million)
|
|
21
|
+
usage = data["usage"].is_a?(Hash) ? data["usage"] : {}
|
|
22
|
+
tokens = usage["input_tokens"].to_i
|
|
23
|
+
@lock.synchronize do
|
|
24
|
+
@requests += 1
|
|
25
|
+
@input_tokens += tokens
|
|
26
|
+
if usage["cost"].is_a?(Numeric)
|
|
27
|
+
@cost += usage["cost"].to_f
|
|
28
|
+
elsif price_per_million
|
|
29
|
+
@cost += tokens * price_per_million / 1_000_000.0
|
|
30
|
+
else
|
|
31
|
+
@unpriced = true
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def cost
|
|
37
|
+
@unpriced ? nil : @cost
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def to_h
|
|
41
|
+
{ "requests" => requests, "input_tokens" => input_tokens, "cost_usd" => cost&.round(6) }
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def line
|
|
45
|
+
cost_text = cost ? format("$%.4f", cost) : "cost unknown (no price for a model)"
|
|
46
|
+
"judgelint: #{requests} #{requests == 1 ? 'request' : 'requests'}, #{Usage.delimit(input_tokens)} input tokens, #{cost_text}"
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.delimit(number)
|
|
50
|
+
number.to_s.reverse.scan(/\d{1,3}/).join(",").reverse
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Judgelint
|
|
4
|
+
# One rule's outcome for one file.
|
|
5
|
+
#
|
|
6
|
+
# +status+ is :violation (at or above the rule's threshold), :uncertain
|
|
7
|
+
# (between warn_at and the threshold), :pass, :skipped (the file is too
|
|
8
|
+
# large to judge), :refused (the model declined the question), or
|
|
9
|
+
# :suppressed (a judgelint:disable comment covers it). Skipped and refused
|
|
10
|
+
# files are reported, never silently passed. +lines+ is the [first, last]
|
|
11
|
+
# range the verdict came from.
|
|
12
|
+
#
|
|
13
|
+
# An example's verdict also knows the answer it should have had: +expected+
|
|
14
|
+
# is :pass or :fail for files under rules/<id>/pass and rules/<id>/fail.
|
|
15
|
+
# Not Data.define: Spinel can't compile a Data or Struct in a program that also uses net/https and json.
|
|
16
|
+
class Verdict
|
|
17
|
+
attr_reader :rule, :path, :probability, :status, :lines, :expected
|
|
18
|
+
|
|
19
|
+
def initialize(rule:, path:, probability:, status:, lines: nil, expected: nil)
|
|
20
|
+
@rule = rule
|
|
21
|
+
@path = path
|
|
22
|
+
@probability = probability
|
|
23
|
+
@status = status
|
|
24
|
+
@lines = lines
|
|
25
|
+
@expected = expected
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def as_example(expected)
|
|
29
|
+
Verdict.new(rule: rule, path: path, probability: probability, status: status, lines: lines, expected: expected)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# +probability+ is the probability the code breaks the rule.
|
|
33
|
+
def self.classify(rule, path, probability, warn_at:, lines: nil)
|
|
34
|
+
status =
|
|
35
|
+
if probability >= rule.threshold then :violation
|
|
36
|
+
elsif probability >= warn_at then :uncertain
|
|
37
|
+
else :pass
|
|
38
|
+
end
|
|
39
|
+
new(rule: rule, path: path, probability: probability, status: status, lines: lines)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def failing?
|
|
43
|
+
status == :violation && rule.error?
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# A refused, skipped, or suppressed example is a miss either way: the rule
|
|
47
|
+
# didn't work on it.
|
|
48
|
+
def correct?
|
|
49
|
+
return false if %i[refused skipped suppressed].include?(status)
|
|
50
|
+
|
|
51
|
+
(status == :violation) == (expected == :fail)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def to_h
|
|
55
|
+
{
|
|
56
|
+
"path" => path, "lines" => lines, "rule" => rule.id, "severity" => rule.severity, "status" => status.to_s,
|
|
57
|
+
"probability" => probability&.round(3), "message" => rule.message
|
|
58
|
+
}.merge(expected ? { "expected" => expected.to_s, "correct" => correct? } : {})
|
|
59
|
+
end
|
|
60
|
+
end
|
|
61
|
+
end
|
data/lib/judgelint.rb
ADDED
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require "yaml"
|
|
5
|
+
require "digest"
|
|
6
|
+
require "fileutils"
|
|
7
|
+
require "net/https"
|
|
8
|
+
require "open3"
|
|
9
|
+
|
|
10
|
+
require_relative "judgelint/version"
|
|
11
|
+
require_relative "judgelint/errors"
|
|
12
|
+
require_relative "judgelint/glob"
|
|
13
|
+
require_relative "judgelint/rule"
|
|
14
|
+
require_relative "judgelint/spec"
|
|
15
|
+
require_relative "judgelint/source"
|
|
16
|
+
require_relative "judgelint/question"
|
|
17
|
+
require_relative "judgelint/changes"
|
|
18
|
+
require_relative "judgelint/providers"
|
|
19
|
+
require_relative "judgelint/config"
|
|
20
|
+
require_relative "judgelint/cache"
|
|
21
|
+
require_relative "judgelint/usage"
|
|
22
|
+
require_relative "judgelint/pool"
|
|
23
|
+
require_relative "judgelint/verdict"
|
|
24
|
+
require_relative "judgelint/protocol"
|
|
25
|
+
require_relative "judgelint/client"
|
|
26
|
+
require_relative "judgelint/runner"
|
|
27
|
+
require_relative "judgelint/reporter"
|
|
28
|
+
require_relative "judgelint/scaffold"
|
|
29
|
+
require_relative "judgelint/cli"
|
|
30
|
+
|
|
31
|
+
# Lints files against conventions that only a reader can check. Each rule is a
|
|
32
|
+
# yes/no question that a System One decision model answers with a probability.
|
|
33
|
+
module Judgelint
|
|
34
|
+
NUMBER = /\A-?\d+(?:\.\d+)?(?:[eE][-+]?\d+)?\z/
|
|
35
|
+
|
|
36
|
+
# Float(), without the rescue: answers nil for anything that isn't a number.
|
|
37
|
+
def self.number(value)
|
|
38
|
+
string = value.to_s.strip
|
|
39
|
+
NUMBER.match?(string) ? string.to_f : nil
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def self.read_yaml(path, required:)
|
|
43
|
+
return parse_yaml(File.read(path), path) if File.exist?(path)
|
|
44
|
+
raise ConfigError, "No config file at #{path}" if required
|
|
45
|
+
|
|
46
|
+
{}
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def self.parse_yaml(yaml, path)
|
|
50
|
+
data = YAML.safe_load(yaml) || {}
|
|
51
|
+
raise ConfigError, "#{path} must be a mapping" unless data.is_a?(Hash)
|
|
52
|
+
|
|
53
|
+
data
|
|
54
|
+
rescue Psych::SyntaxError => e
|
|
55
|
+
raise ConfigError, "#{path}: #{e.message}"
|
|
56
|
+
end
|
|
57
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Ships only in judgelint's platform gems, which carry the native binary in
|
|
4
|
+
# libexec/. After installing one, point the judgelint command straight at the
|
|
5
|
+
# binary instead of at RubyGems' Ruby wrapper, which spends about 40 ms
|
|
6
|
+
# loading Ruby and RubyGems before it gets there.
|
|
7
|
+
Gem.post_install do |installer|
|
|
8
|
+
next unless installer.spec.name == "judgelint"
|
|
9
|
+
|
|
10
|
+
native = File.join(installer.spec.full_gem_path, "libexec", "judgelint")
|
|
11
|
+
command = File.join(installer.bin_dir, "judgelint")
|
|
12
|
+
next unless File.executable?(native) && File.file?(command)
|
|
13
|
+
|
|
14
|
+
File.delete(command)
|
|
15
|
+
File.symlink(native, command)
|
|
16
|
+
end
|
data/libexec/judgelint
ADDED
|
Binary file
|
metadata
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
--- !ruby/object:Gem::Specification
|
|
2
|
+
name: judgelint
|
|
3
|
+
version: !ruby/object:Gem::Version
|
|
4
|
+
version: 0.1.0
|
|
5
|
+
platform: aarch64-linux
|
|
6
|
+
authors:
|
|
7
|
+
- JP Camara
|
|
8
|
+
bindir: exe
|
|
9
|
+
cert_chain: []
|
|
10
|
+
date: 1980-01-02 00:00:00.000000000 Z
|
|
11
|
+
dependencies: []
|
|
12
|
+
description: |
|
|
13
|
+
judgelint checks files against conventions that only a reader can judge. Each
|
|
14
|
+
rule is a yes/no question answered by a fast decision model such as TypeSafe's
|
|
15
|
+
Jev over the System One protocol. It also compiles to a native binary with
|
|
16
|
+
Spinel.
|
|
17
|
+
email:
|
|
18
|
+
- johnpcamara@gmail.com
|
|
19
|
+
executables:
|
|
20
|
+
- judgelint
|
|
21
|
+
extensions: []
|
|
22
|
+
extra_rdoc_files: []
|
|
23
|
+
files:
|
|
24
|
+
- CHANGELOG.md
|
|
25
|
+
- LICENSE
|
|
26
|
+
- README.md
|
|
27
|
+
- exe/judgelint
|
|
28
|
+
- lib/judgelint.rb
|
|
29
|
+
- lib/judgelint/cache.rb
|
|
30
|
+
- lib/judgelint/changes.rb
|
|
31
|
+
- lib/judgelint/cli.rb
|
|
32
|
+
- lib/judgelint/client.rb
|
|
33
|
+
- lib/judgelint/config.rb
|
|
34
|
+
- lib/judgelint/errors.rb
|
|
35
|
+
- lib/judgelint/glob.rb
|
|
36
|
+
- lib/judgelint/pool.rb
|
|
37
|
+
- lib/judgelint/protocol.rb
|
|
38
|
+
- lib/judgelint/providers.rb
|
|
39
|
+
- lib/judgelint/question.rb
|
|
40
|
+
- lib/judgelint/reporter.rb
|
|
41
|
+
- lib/judgelint/rule.rb
|
|
42
|
+
- lib/judgelint/runner.rb
|
|
43
|
+
- lib/judgelint/scaffold.rb
|
|
44
|
+
- lib/judgelint/source.rb
|
|
45
|
+
- lib/judgelint/spec.rb
|
|
46
|
+
- lib/judgelint/usage.rb
|
|
47
|
+
- lib/judgelint/verdict.rb
|
|
48
|
+
- lib/judgelint/version.rb
|
|
49
|
+
- lib/rubygems_plugin.rb
|
|
50
|
+
- libexec/judgelint
|
|
51
|
+
homepage: https://github.com/jpcamara/judgelint
|
|
52
|
+
licenses:
|
|
53
|
+
- MIT
|
|
54
|
+
metadata:
|
|
55
|
+
source_code_uri: https://github.com/jpcamara/judgelint
|
|
56
|
+
changelog_uri: https://github.com/jpcamara/judgelint/blob/main/CHANGELOG.md
|
|
57
|
+
rubygems_mfa_required: 'true'
|
|
58
|
+
rdoc_options: []
|
|
59
|
+
require_paths:
|
|
60
|
+
- lib
|
|
61
|
+
required_ruby_version: !ruby/object:Gem::Requirement
|
|
62
|
+
requirements:
|
|
63
|
+
- - ">="
|
|
64
|
+
- !ruby/object:Gem::Version
|
|
65
|
+
version: '3.2'
|
|
66
|
+
required_rubygems_version: !ruby/object:Gem::Requirement
|
|
67
|
+
requirements:
|
|
68
|
+
- - ">="
|
|
69
|
+
- !ruby/object:Gem::Version
|
|
70
|
+
version: '0'
|
|
71
|
+
requirements: []
|
|
72
|
+
rubygems_version: 3.6.9
|
|
73
|
+
specification_version: 4
|
|
74
|
+
summary: Lint for conventions a linter can't check, judged by System One decision
|
|
75
|
+
models.
|
|
76
|
+
test_files: []
|