judgelint 0.1.0-aarch64-linux → 0.1.1-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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 236b98e6eeba314b1a69758ce7db0a6067fcd8aef217e9f997719318c5e1e47f
4
- data.tar.gz: 4f5b1ee0f8cdea9fe698acfa295e36c7f5610c26a50631808876c7fec953a7dc
3
+ metadata.gz: 42bcf351875ac39720d5a4721f55c58ce024cf2fd83c8582dcdc83bbbf42eb2d
4
+ data.tar.gz: 33975d773a0363ab0861f0a31a1be3f7eae7f3bcc105cf5a782ce812f992a06a
5
5
  SHA512:
6
- metadata.gz: b29d71ec222699bf0022ceb6ccea9f947eacbf7eec96d8822c8dcd1a9b19e6739ad0f8ee4c062e7a578bee232fb48a20af49ca74ff19fbc0b59c11cdc8d1bdee
7
- data.tar.gz: 66535560c0208535f73c4825f4d6d8ab4729902bdadf3f18abb9305af19f83d6d6bbed8dd7d0d56fef1577aac9e21a26c49fc6114fc3c9d3d4d2736ec3ce9cbd
6
+ metadata.gz: b813f42cc6fc08aa5a94937e80c5531694e5538a6792ddc475e7a4f4c9c5ff63e9c5e8a7357c5f80d074973bb5ca74edc8eb4c37e299ccda437e5d31715987db
7
+ data.tar.gz: df2da7db05119dac2880a8a992e74ba704cb17c6026e7e3b62a4ea7c692f345171f2f7decc2899fa8a3c8b544769e506d0023d07711bde28733073cb3a3f09ef
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. Ruby with
13
- no dependencies; also a native binary, built with
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
- gem install judgelint
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
- **Suppress** a finding where a person has decided the code is fine, with a
115
- reason: `# judgelint:disable <rule> -- <reason>` covers the 40 lines it's in,
116
- and `judgelint:disable-file` the whole file. Any comment style works.
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
- On macOS and Linux, `gem install` picks a platform gem with the binary
206
- inside, statically linked, and links the `judgelint` command straight to it;
207
- elsewhere, Windows included, the gem runs as plain Ruby. The binary starts in
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
@@ -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
@@ -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: "-", suppressed: "-" }.freeze
9
- STATUSES = %w[violation uncertain pass skipped refused suppressed].freeze
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[:suppressed]} suppressed" if counts[:suppressed]
79
+ parts << "#{counts[:excused]} excused by judgehints" if counts[:excused]
80
80
  parts.join(", ")
81
81
  end
82
82
 
@@ -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, :suppressed, :answers, :requests, :expected
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, suppressed, prose: nil, expected: nil)
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
- kept = ranges.reject { |first, last| source.suppressed?(rule.id, first, last) }
156
- if kept.empty?
157
- suppressed << rule
158
- else
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, suppressed, prose: rules.first&.prose, expected: expected)
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
- windows = []
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
- values << question.violation(yes) unless yes.nil?
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
- windows << [first, last, values.sum / values.size] unless values.empty?
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 windows.empty?
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]
@@ -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
- # 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.
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
- SUPPRESSION = %r{(?:#|//|--|/\*|<!--|;)\s*judgelint:(disable-file|disable)\b(.*)\z}
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
- @disabled = {}
32
- @disabled_file = []
33
- @files.each { |file, lines| find_suppressions(file, lines) }
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 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
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
@@ -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
- # :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.
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, skipped, or suppressed example is a miss either way: the rule
47
- # didn't work on it.
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 suppressed].include?(status)
49
+ return false if %i[refused skipped].include?(status)
50
50
 
51
51
  (status == :violation) == (expected == :fail)
52
52
  end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Judgelint
4
- VERSION = "0.1.0"
4
+ VERSION = "0.1.1"
5
5
  end
data/libexec/judgelint CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: judgelint
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.1.1
5
5
  platform: aarch64-linux
6
6
  authors:
7
7
  - JP Camara