judgelint 0.1.0 → 0.1.1
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 +8 -0
- data/README.md +25 -10
- data/lib/judgelint/question.rb +23 -1
- data/lib/judgelint/reporter.rb +3 -3
- data/lib/judgelint/runner.rb +44 -24
- data/lib/judgelint/source.rb +10 -36
- data/lib/judgelint/verdict.rb +7 -7
- data/lib/judgelint/version.rb +1 -1
- metadata +1 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 40611f59da97dfbd70e79f986b721167b36d10032765e500b8e9909324d921c4
|
|
4
|
+
data.tar.gz: 3191b9a031b9c1d37624c90e7cac21629e1b62126e77e79086c3b016a6cb781f
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 7bd6ed8ec5eafae61e94cbb5e766bf6dcfec2e8531f49030f4b28fde3ea4702b09da3049e589d9f1132ac091d3b6618e07025b92247bd2e5c22a81fd36f7a66b
|
|
7
|
+
data.tar.gz: 9f49768dca04ddb9982b4e38521b6070f3ad2dd241ab58d3affde1498ea17b3420dfd764329b94051fbc37e25f315c3788fa516181bb84b225690f0f99e541c0
|
data/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,13 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.1.1 (2026-10-23)
|
|
4
|
+
|
|
5
|
+
- `judgehint:` comments replace `judgelint:disable`. A hint says why the code
|
|
6
|
+
is the way it is; each rule asks whether it gives a reason the code may
|
|
7
|
+
break that rule, and reports the finding as excused if so.
|
|
8
|
+
- Native binaries for macOS and Linux on each GitHub release, installable
|
|
9
|
+
with Homebrew (`brew install jpcamara/tap/judgelint`) or `install.sh`.
|
|
10
|
+
|
|
3
11
|
## 0.1.0 (2026-10-23)
|
|
4
12
|
|
|
5
13
|
- Specs as praxis reads them: a README (or `*.judgelint.md`) with `paths:`
|
data/README.md
CHANGED
|
@@ -9,14 +9,17 @@ Answers take a few hundred milliseconds and cost a fraction of a cent.
|
|
|
9
9
|
|
|
10
10
|
Inspired by [praxis](https://github.com/zarpay/praxis-cli), which reads the
|
|
11
11
|
same kind of specs with chat models. judgelint trades praxis's written
|
|
12
|
-
findings for speed and cost, and reads praxis's specs as they are.
|
|
13
|
-
no dependencies
|
|
14
|
-
[Spinel](https://github.com/matz/spinel).
|
|
12
|
+
findings for speed and cost, and reads praxis's specs as they are. Written in
|
|
13
|
+
Ruby with no dependencies, and compiled to a native binary with
|
|
14
|
+
[Spinel](https://github.com/matz/spinel), so it doesn't need Ruby to run.
|
|
15
15
|
|
|
16
16
|
## Quick start
|
|
17
17
|
|
|
18
18
|
```sh
|
|
19
|
-
|
|
19
|
+
brew install jpcamara/tap/judgelint
|
|
20
|
+
# or: curl -fsSL https://raw.githubusercontent.com/jpcamara/judgelint/main/install.sh | sh
|
|
21
|
+
# or: gem install judgelint
|
|
22
|
+
|
|
20
23
|
judgelint init # .judgelint.yml and an example spec
|
|
21
24
|
export TYPESAFE_API_KEY=...
|
|
22
25
|
judgelint test # judge the spec's examples
|
|
@@ -111,9 +114,17 @@ Every file in a feature is reachable from index.ts.
|
|
|
111
114
|
`fail_when: no` inverts a question like "Does this service validate its
|
|
112
115
|
input first?".
|
|
113
116
|
|
|
114
|
-
**
|
|
115
|
-
|
|
116
|
-
|
|
117
|
+
**Explain an exception** with a `judgehint:` comment, in any comment style:
|
|
118
|
+
|
|
119
|
+
```ts
|
|
120
|
+
// judgehint: throwing is intentional. Only the nightly job calls this, after
|
|
121
|
+
// checking the review, so a bad id here is a programmer error.
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
For each rule, code with a hint in its 40 lines is also asked whether the
|
|
125
|
+
hint gives a reason it may break that rule. If so, the finding is reported as
|
|
126
|
+
excused rather than as a violation. A hint excuses only the rule its reason
|
|
127
|
+
is about, and one with no reason ("this is intentional") excuses nothing.
|
|
117
128
|
|
|
118
129
|
## Examples
|
|
119
130
|
|
|
@@ -202,9 +213,13 @@ SPINEL=../spinel/bin/spinel rake spinel # build/judgelint
|
|
|
202
213
|
SPINEL=../spinel/bin/spinel rake test:spinel # the tests, against it too
|
|
203
214
|
```
|
|
204
215
|
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
|
|
216
|
+
Homebrew and `install.sh` (which installs to `~/.local/bin`, or
|
|
217
|
+
`$JUDGELINT_INSTALL_DIR`, and takes `JUDGELINT_VERSION=v0.1.0`) install the
|
|
218
|
+
binary from the [releases](https://github.com/jpcamara/judgelint/releases),
|
|
219
|
+
for macOS and Linux on arm64 and x86_64; the Linux builds need glibc 2.35 or
|
|
220
|
+
newer. On those platforms `gem install` picks a platform gem with the same
|
|
221
|
+
binary inside and links the `judgelint` command straight to it; elsewhere,
|
|
222
|
+
Windows included, the gem runs as plain Ruby. The binary starts in
|
|
208
223
|
4 ms against CRuby's 110, and in a 300-file benchmark it was 1.4x faster than
|
|
209
224
|
CRuby on one thread and 2x on eight. Under Bundler, `bundle exec` spends about
|
|
210
225
|
100 ms loading Bundler first; to skip it, link the binary
|
data/lib/judgelint/question.rb
CHANGED
|
@@ -8,21 +8,33 @@ module Judgelint
|
|
|
8
8
|
# answer flipped, whether the code follows it. Decision models lean toward
|
|
9
9
|
# no on the first and yes on the second, so the average is steadier than
|
|
10
10
|
# either.
|
|
11
|
+
#
|
|
12
|
+
# Code with a judgehint comment is also asked whether the hint excuses it
|
|
13
|
+
# from the rule. Asked on its own, a decision model answers that sharply;
|
|
14
|
+
# told about hints inside the rule's question, it only hedges.
|
|
11
15
|
class Question
|
|
12
16
|
BREAKS = "The code clearly breaks the rule."
|
|
13
17
|
FOLLOWS = "The code follows the rule, or the rule doesn't apply to it."
|
|
18
|
+
EXCUSED = "A judgehint comment is about this rule, and gives a reason the code may break it."
|
|
19
|
+
NOT_EXCUSED = "No judgehint comment is about this rule. Any judgehint is about a different rule, or gives no reason."
|
|
14
20
|
|
|
15
21
|
attr_reader :rule, :first, :last
|
|
16
22
|
|
|
17
|
-
def initialize(rule, first, last, inverted: false)
|
|
23
|
+
def initialize(rule, first, last, inverted: false, excuse: false)
|
|
18
24
|
@rule = rule
|
|
19
25
|
@first = first
|
|
20
26
|
@last = last
|
|
21
27
|
@inverted = inverted
|
|
28
|
+
@excuse = excuse
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def excuse?
|
|
32
|
+
@excuse
|
|
22
33
|
end
|
|
23
34
|
|
|
24
35
|
def key
|
|
25
36
|
base = "#{rule.question_key}.#{first}-#{last}"
|
|
37
|
+
return "#{base}.excused" if @excuse
|
|
26
38
|
return base unless rule.convention
|
|
27
39
|
|
|
28
40
|
@inverted ? "#{base}.follows" : "#{base}.breaks"
|
|
@@ -36,6 +48,7 @@ module Judgelint
|
|
|
36
48
|
# "Answer yes only when..." keeps the model from flagging code the rule
|
|
37
49
|
# doesn't cover.
|
|
38
50
|
def instructions
|
|
51
|
+
return excuse_instructions if @excuse
|
|
39
52
|
return convention_instructions if rule.convention
|
|
40
53
|
|
|
41
54
|
text = rule.window? ? "Look at lines #{first}-#{last} of the file; the rest is context. #{rule.question}" : rule.question
|
|
@@ -47,12 +60,14 @@ module Judgelint
|
|
|
47
60
|
end
|
|
48
61
|
|
|
49
62
|
def yes_means
|
|
63
|
+
return EXCUSED if @excuse
|
|
50
64
|
return rule.yes_means unless rule.convention
|
|
51
65
|
|
|
52
66
|
@inverted ? rule.no_means || FOLLOWS : rule.yes_means || BREAKS
|
|
53
67
|
end
|
|
54
68
|
|
|
55
69
|
def no_means
|
|
70
|
+
return NOT_EXCUSED if @excuse
|
|
56
71
|
return rule.no_means unless rule.convention
|
|
57
72
|
|
|
58
73
|
@inverted ? rule.yes_means || BREAKS : rule.no_means || FOLLOWS
|
|
@@ -64,6 +79,13 @@ module Judgelint
|
|
|
64
79
|
|
|
65
80
|
private
|
|
66
81
|
|
|
82
|
+
def excuse_instructions
|
|
83
|
+
where = { "window" => "in lines #{first}-#{last} of the file", "file" => "in the file", "directory" => "in the files" }
|
|
84
|
+
.fetch(rule.scope)
|
|
85
|
+
"Does a judgehint comment #{where} explain why the code intentionally departs from the following team " \
|
|
86
|
+
"rule, with a reason specific to this rule? Rule: #{rule.convention || rule.message}"
|
|
87
|
+
end
|
|
88
|
+
|
|
67
89
|
# The spec's prose is in the state as team_rules, so the bullet is "the
|
|
68
90
|
# following team rule".
|
|
69
91
|
def convention_instructions
|
data/lib/judgelint/reporter.rb
CHANGED
|
@@ -5,8 +5,8 @@ module Judgelint
|
|
|
5
5
|
# goes to stdout; anything else goes to stderr.
|
|
6
6
|
class Reporter
|
|
7
7
|
FORMATS = %w[text json github sarif].freeze
|
|
8
|
-
MARKS = { violation: "x", uncertain: "?", skipped: "-", refused: "-",
|
|
9
|
-
STATUSES = %w[violation uncertain pass skipped refused
|
|
8
|
+
MARKS = { violation: "x", uncertain: "?", skipped: "-", refused: "-", excused: "-" }.freeze
|
|
9
|
+
STATUSES = %w[violation uncertain pass skipped refused excused].freeze
|
|
10
10
|
|
|
11
11
|
def initialize(out, format:)
|
|
12
12
|
@out = out
|
|
@@ -76,7 +76,7 @@ module Judgelint
|
|
|
76
76
|
parts << "#{counts[:uncertain]} uncertain" if counts[:uncertain]
|
|
77
77
|
parts << "#{counts[:skipped]} skipped (too large)" if counts[:skipped]
|
|
78
78
|
parts << "#{counts[:refused]} refused by the model" if counts[:refused]
|
|
79
|
-
parts << "#{counts[:
|
|
79
|
+
parts << "#{counts[:excused]} excused by judgehints" if counts[:excused]
|
|
80
80
|
parts.join(", ")
|
|
81
81
|
end
|
|
82
82
|
|
data/lib/judgelint/runner.rb
CHANGED
|
@@ -14,18 +14,17 @@ module Judgelint
|
|
|
14
14
|
# One file's work: the questions to ask, each model's answers (from the
|
|
15
15
|
# cache or the model), and the requests still to send.
|
|
16
16
|
class Job
|
|
17
|
-
attr_reader :source, :state, :state_digest, :questions, :
|
|
17
|
+
attr_reader :source, :state, :state_digest, :questions, :answers, :requests, :expected
|
|
18
18
|
attr_accessor :skipped
|
|
19
19
|
|
|
20
20
|
# The state is built and hashed once: it's the whole file, and every
|
|
21
21
|
# question's cache key needs its hash. Rules from a spec read its prose
|
|
22
22
|
# alongside the file, as team_rules.
|
|
23
|
-
def initialize(source, questions,
|
|
23
|
+
def initialize(source, questions, prose: nil, expected: nil)
|
|
24
24
|
@source = source
|
|
25
25
|
@state = prose ? { "team_rules" => prose, "file" => source.state } : source.state
|
|
26
26
|
@state_digest = Digest::SHA256.hexdigest(JSON.generate(@state))
|
|
27
27
|
@questions = questions
|
|
28
|
-
@suppressed = suppressed
|
|
29
28
|
@answers = {} # model index => {question key => probability or nil}
|
|
30
29
|
@requests = []
|
|
31
30
|
@expected = expected
|
|
@@ -143,28 +142,20 @@ module Judgelint
|
|
|
143
142
|
end
|
|
144
143
|
|
|
145
144
|
def plan_file(source, rules, lines, expected: nil)
|
|
146
|
-
unknown = source.suppressed_ids - @rules.map(&:id)
|
|
147
|
-
raise ConfigError, "#{source.path}: judgelint:disable names unknown rules #{unknown.join(', ')}" unless unknown.empty?
|
|
148
|
-
|
|
149
145
|
questions = {}
|
|
150
|
-
suppressed = []
|
|
151
146
|
rules.each do |rule|
|
|
152
147
|
ranges = rule.ranges(source, Config::WINDOW_LINES).select { |first, last| touched?(lines, first, last) }
|
|
153
148
|
next if ranges.empty?
|
|
154
149
|
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
asked
|
|
160
|
-
kept.each do |first, last|
|
|
161
|
-
asked << Question.new(rule, first, last)
|
|
162
|
-
asked << Question.new(rule, first, last, inverted: true) if rule.convention
|
|
163
|
-
end
|
|
164
|
-
questions[rule] = asked
|
|
150
|
+
asked = []
|
|
151
|
+
ranges.each do |first, last|
|
|
152
|
+
asked << Question.new(rule, first, last)
|
|
153
|
+
asked << Question.new(rule, first, last, inverted: true) if rule.convention
|
|
154
|
+
asked << Question.new(rule, first, last, excuse: true) if source.hinted?(first, last)
|
|
165
155
|
end
|
|
156
|
+
questions[rule] = asked
|
|
166
157
|
end
|
|
167
|
-
job = Job.new(source, questions,
|
|
158
|
+
job = Job.new(source, questions, prose: rules.first&.prose, expected: expected)
|
|
168
159
|
pack(job)
|
|
169
160
|
job
|
|
170
161
|
end
|
|
@@ -236,28 +227,57 @@ module Judgelint
|
|
|
236
227
|
def verdicts(job)
|
|
237
228
|
path = job.source.path
|
|
238
229
|
found = job.questions.map { |rule, questions| verdict_for(rule, path, questions, job) }
|
|
239
|
-
found += job.suppressed.map { |rule| Verdict.new(rule: rule, path: path, probability: nil, status: :suppressed) }
|
|
240
230
|
job.expected ? found.map { |verdict| verdict.as_example(job.expected) } : found
|
|
241
231
|
end
|
|
242
232
|
|
|
243
233
|
# A window's probability of a violation is the average over its questions
|
|
244
234
|
# (a bullet is asked both ways) and the models; a rule's verdict is its
|
|
245
|
-
# worst window.
|
|
235
|
+
# worst window. A window whose judgehint excuses it, by the same average
|
|
236
|
+
# and threshold, is reported as excused instead, unless another window
|
|
237
|
+
# breaks the rule.
|
|
246
238
|
def verdict_for(rule, path, questions, job)
|
|
247
239
|
return Verdict.new(rule: rule, path: path, probability: nil, status: :skipped) if job.skipped
|
|
248
240
|
|
|
249
|
-
|
|
241
|
+
open = []
|
|
242
|
+
excused = []
|
|
250
243
|
questions.group_by { |question| [question.first, question.last] }.each do |(first, last), asked|
|
|
251
244
|
values = []
|
|
245
|
+
excuses = []
|
|
252
246
|
asked.each do |question|
|
|
253
247
|
job.answers.each_value do |answers|
|
|
254
248
|
yes = answers[question.key]
|
|
255
|
-
|
|
249
|
+
next if yes.nil?
|
|
250
|
+
|
|
251
|
+
if question.excuse?
|
|
252
|
+
excuses << yes
|
|
253
|
+
else
|
|
254
|
+
values << question.violation(yes)
|
|
255
|
+
end
|
|
256
256
|
end
|
|
257
257
|
end
|
|
258
|
-
|
|
258
|
+
next if values.empty?
|
|
259
|
+
|
|
260
|
+
window = [first, last, values.sum / values.size]
|
|
261
|
+
if !excuses.empty? && excuses.sum / excuses.size >= rule.threshold
|
|
262
|
+
excused << window
|
|
263
|
+
else
|
|
264
|
+
open << window
|
|
265
|
+
end
|
|
259
266
|
end
|
|
260
|
-
return Verdict.new(rule: rule, path: path, probability: nil, status: :refused) if
|
|
267
|
+
return Verdict.new(rule: rule, path: path, probability: nil, status: :refused) if open.empty? && excused.empty?
|
|
268
|
+
|
|
269
|
+
found = worst(rule, path, open)
|
|
270
|
+
return found if found && found.status != :pass
|
|
271
|
+
|
|
272
|
+
hidden = worst(rule, path, excused)
|
|
273
|
+
return found || hidden if hidden.nil? || hidden.status == :pass
|
|
274
|
+
|
|
275
|
+
Verdict.new(rule: rule, path: path, probability: hidden.probability, status: :excused, lines: hidden.lines)
|
|
276
|
+
end
|
|
277
|
+
|
|
278
|
+
# The worst of +windows+ ([first, last, probability]), or nil if there are none.
|
|
279
|
+
def worst(rule, path, windows)
|
|
280
|
+
return nil if windows.empty?
|
|
261
281
|
|
|
262
282
|
first, last, probability = windows.max_by { |window| window[2] }
|
|
263
283
|
lines = rule.directory? ? nil : [first, last]
|
data/lib/judgelint/source.rb
CHANGED
|
@@ -5,14 +5,11 @@ module Judgelint
|
|
|
5
5
|
# line numbered so a question can point at a range, followed by any context
|
|
6
6
|
# files the rule names, which are there to read and never judged.
|
|
7
7
|
#
|
|
8
|
-
#
|
|
9
|
-
#
|
|
10
|
-
#
|
|
11
|
-
# and `judgelint:disable-file <rule ids> -- <reason>` covers the whole file.
|
|
12
|
-
# In a directory, either one covers the directory.
|
|
8
|
+
# A judgehint is a comment (#, //, --, /*, <!--, or ;) starting with
|
|
9
|
+
# `judgehint:` that says why the code is the way it is. The model weighs it
|
|
10
|
+
# for the window it falls in, or for the whole directory.
|
|
13
11
|
class Source
|
|
14
|
-
|
|
15
|
-
REASONED = %r{\A\s+([A-Za-z0-9_./, -]+?)\s+--\s+\S}
|
|
12
|
+
HINT = %r{(?:#|//|--|/\*|<!--|;)\s*judgehint:}
|
|
16
13
|
|
|
17
14
|
attr_reader :path, :shown_as, :line_count
|
|
18
15
|
|
|
@@ -28,9 +25,10 @@ module Judgelint
|
|
|
28
25
|
@files = files.map { |file| [file, File.read(file).lines] }
|
|
29
26
|
@context = context
|
|
30
27
|
@line_count = @files.sum { |_file, lines| lines.size }
|
|
31
|
-
@
|
|
32
|
-
@
|
|
33
|
-
|
|
28
|
+
@hints = []
|
|
29
|
+
@files.each do |_file, lines|
|
|
30
|
+
lines.each_with_index { |line, index| @hints << index + 1 if HINT.match?(line) }
|
|
31
|
+
end
|
|
34
32
|
end
|
|
35
33
|
|
|
36
34
|
def directory?
|
|
@@ -56,15 +54,8 @@ module Judgelint
|
|
|
56
54
|
(1..line_count).step(size).map { |first| [first, [first + size - 1, line_count].min] }
|
|
57
55
|
end
|
|
58
56
|
|
|
59
|
-
def
|
|
60
|
-
|
|
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
|
|
57
|
+
def hinted?(first, last)
|
|
58
|
+
directory? ? !@hints.empty? : @hints.any? { |line| line.between?(first, last) }
|
|
68
59
|
end
|
|
69
60
|
|
|
70
61
|
private
|
|
@@ -77,22 +68,5 @@ module Judgelint
|
|
|
77
68
|
def shown(file)
|
|
78
69
|
"#{shown_as}/#{file.delete_prefix("#{path}/")}"
|
|
79
70
|
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
71
|
end
|
|
98
72
|
end
|
data/lib/judgelint/verdict.rb
CHANGED
|
@@ -5,10 +5,10 @@ module Judgelint
|
|
|
5
5
|
#
|
|
6
6
|
# +status+ is :violation (at or above the rule's threshold), :uncertain
|
|
7
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
|
-
#
|
|
10
|
-
# files are reported, never silently passed. +lines+ is the [first,
|
|
11
|
-
# range the verdict came from.
|
|
8
|
+
# large to judge), :refused (the model declined the question), or :excused
|
|
9
|
+
# (a violation or uncertain, but a judgehint excuses it). Skipped and
|
|
10
|
+
# refused files are reported, never silently passed. +lines+ is the [first,
|
|
11
|
+
# last] range the verdict came from.
|
|
12
12
|
#
|
|
13
13
|
# An example's verdict also knows the answer it should have had: +expected+
|
|
14
14
|
# is :pass or :fail for files under rules/<id>/pass and rules/<id>/fail.
|
|
@@ -43,10 +43,10 @@ module Judgelint
|
|
|
43
43
|
status == :violation && rule.error?
|
|
44
44
|
end
|
|
45
45
|
|
|
46
|
-
# A refused
|
|
47
|
-
#
|
|
46
|
+
# A refused or skipped example is a miss either way: the rule didn't work
|
|
47
|
+
# on it. An excused one isn't a violation.
|
|
48
48
|
def correct?
|
|
49
|
-
return false if %i[refused skipped
|
|
49
|
+
return false if %i[refused skipped].include?(status)
|
|
50
50
|
|
|
51
51
|
(status == :violation) == (expected == :fail)
|
|
52
52
|
end
|
data/lib/judgelint/version.rb
CHANGED