judgelint 0.1.9 → 0.1.10

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: a90b0ee415a2b08188fd8141eef3ea4f725e3317a886b487d86ed4d1463c88a4
4
- data.tar.gz: e0c57f2b25e7775a9dcedae5be947de53e8a1bc6ef363d4f07c3a601c8bcfb8d
3
+ metadata.gz: fe98d85a793082f8e0660331fd151d1d169ec16937f7a187f4f30c3e52058c54
4
+ data.tar.gz: 54aefdc8a09b29cf66ead37809cc5b2a78df0758a5753ab3cc2411a2d3d35f1d
5
5
  SHA512:
6
- metadata.gz: 8849be7a19c2fe4e64304109120856ae8adbd02e3b6cca0894b6cecc2a56be27fa9af3f5e7c805d8c32f6df73e40f42a7b0b2e15e8628c93b938371d605651a8
7
- data.tar.gz: 3df1f283287488314cbb5fb8c2ac21f8b79ead7ed94ce5eb4a2354fd93ca2979f3984a73a24898a4c9a8467c29c2fe698887c52aef7deae2b001e4a1d87d7913
6
+ metadata.gz: f9212995fc03c6f61589de5f8f0293d10ddde6003176037747d704789326fe95b4c16e0e61584b7c1bfdda868c2576c7381db8d38eeadfeb66aee96dc3b0af6c
7
+ data.tar.gz: 5ddefc93e017f7313eae0f4ea844cc7416ac58b0e1fc763c32c6800c7800e5a1baa0f74c025743076d89eadc1258bc6339b3045c996c553eb2dc86b72f3abe95
data/CHANGELOG.md CHANGED
@@ -1,5 +1,18 @@
1
1
  # Changelog
2
2
 
3
+ ## 0.1.10 (2026-10-27)
4
+
5
+ - `check` and `test` use far less memory and run faster. Against 0.1.9 on
6
+ a synthetic project, the native binary uses 84–89% less memory, and
7
+ Ruby 73–87% less. Both are 23–51% faster.
8
+ - Files of the same shape share their questions.
9
+ - A planned file is read again when its request is sent instead of being
10
+ kept. A file that changed in between stops the run with an error.
11
+ - The cache stays on disk and is looked up an entry at a time.
12
+ - Connections to the model are kept open between requests.
13
+ - Each glob pattern is walked once.
14
+ - Output and cache files are unchanged.
15
+
3
16
  ## 0.1.9 (2026-10-27)
4
17
 
5
18
  - `check` uses much less memory on large projects. On 50,000 files its
@@ -4,12 +4,43 @@ module Judgelint
4
4
  # Stores each answer under a hash of everything that produced it: the state
5
5
  # the model saw (path and numbered contents), the question asked of it, and
6
6
  # the model. Changing any of them is a miss. Safe to share across threads.
7
+ #
8
+ # A cache file in the form #save writes stays on disk: loading it checks
9
+ # every entry and keeps its key and where it is, and a lookup finds the
10
+ # key by binary search and reads that entry back. Any other JSON object is
11
+ # read whole. New answers wait for #save in a scratch file beside the
12
+ # cache, already in the form it writes. A cache used again after it's saved
13
+ # reads its new file first.
7
14
  class Cache
15
+ # A line that starts an entry, without its line ending: its key, then
16
+ # its value or the value's first line.
17
+ ENTRY = /\A "([0-9a-f]{64})": (.*)\z/
18
+ # Values in the plain form #save writes, which parse without being
19
+ # parsed: a number or literal, or a line of a hash of them with an ASCII
20
+ # name. Anything else is parsed to check it.
21
+ SCALAR = /\A(?:-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|null|true|false)\z/
22
+ # Spinel's JSON parser outruns its regexps, where CRuby's parser makes
23
+ # more garbage than its regexps do, so only CRuby checks by pattern.
24
+ BY_PATTERN = RUBY_ENGINE != "spinel"
25
+ MEMBER = %r{\A "(?:[\x20\x21\x23-\x5b\x5d-\x7e]|\\["\\/bfnrt])*": (?:-?(?:0|[1-9][0-9]*)(?:\.[0-9]+)?(?:[eE][+-]?[0-9]+)?|null|true|false),?\r?\n\z}
26
+ KEY_SIZE = 64
27
+ # Where a value starts in its entry's first line.
28
+ VALUE_AT = 70
29
+ # A value's place in a file is kept as one Integer: its offset, shifted
30
+ # past its length.
31
+ SPAN_BITS = 32
32
+
8
33
  def initialize(path)
9
34
  @path = path
10
- @entries = load_entries
35
+ @keys = +"" # the file's keys in order, end to end
36
+ @places = [] # the place of each key's value in the file
37
+ @entries = {} # every answer from a file read whole, or stored without a file
38
+ @stored_keys = +"" # the keys of answers stored this run, end to end, in the order stored
39
+ @stored_places = [] # the place of each in the scratch file
11
40
  @dirty = false
41
+ @saved = false
12
42
  @lock = Mutex.new
43
+ load_entries
13
44
  end
14
45
 
15
46
  def self.key(question_digest, state_digest, model_label)
@@ -17,37 +48,297 @@ module Judgelint
17
48
  end
18
49
 
19
50
  def fetch(key)
20
- @lock.synchronize { @entries[key] }
51
+ @lock.synchronize do
52
+ reload if @saved
53
+ if (stored = stored(key))
54
+ read(@scratch, @stored_places[stored])
55
+ elsif (found = find(key))
56
+ read(@file, @places[found])
57
+ else
58
+ @entries[key]
59
+ end
60
+ end
61
+ end
62
+
63
+ # Makes room for about +count+ answers to be stored, so their keys don't
64
+ # grow a piece at a time.
65
+ def reserve(count)
66
+ @lock.synchronize do
67
+ return unless @path
68
+
69
+ reload if @saved
70
+ @stored_keys = String.new(capacity: count * KEY_SIZE) << @stored_keys if @stored_keys.bytesize < count * KEY_SIZE
71
+ end
21
72
  end
22
73
 
23
74
  def store(key, yes_probability)
24
75
  @lock.synchronize do
25
- @entries[key] = yes_probability
76
+ reload if @saved
26
77
  @dirty = true
78
+ if @path
79
+ text = indented(yes_probability)
80
+ @scratch ||= scratch
81
+ @stored_keys << key
82
+ @stored_places << place(@scratch_size, text.bytesize)
83
+ @scratch.write(text)
84
+ @scratch.flush
85
+ @scratch_size += text.bytesize
86
+ else
87
+ @entries[key] = yes_probability
88
+ end
27
89
  end
28
90
  end
29
91
 
92
+ # Writes every entry in key order, as JSON.pretty_generate does, one at a
93
+ # time into a new file that then replaces the old one.
30
94
  def save
31
95
  @lock.synchronize do
32
96
  return unless @path && @dirty
33
97
 
34
98
  FileUtils.mkdir_p(File.dirname(@path))
35
- File.write(@path, "#{JSON.pretty_generate(@entries.sort.to_h)}\n")
99
+ temp = "#{@path}.#{Process.pid}.tmp"
100
+ File.open(temp, "w") { |out| write(out) }
101
+ close
102
+ File.rename(temp, @path)
36
103
  @dirty = false
104
+ @saved = true
37
105
  end
38
106
  end
39
107
 
40
108
  private
41
109
 
110
+ # The index of +key+ among the file's keys, or nil.
111
+ def find(key)
112
+ low = 0
113
+ high = @places.size - 1
114
+ while low <= high
115
+ middle = (low + high) / 2
116
+ other = @keys.byteslice(middle * KEY_SIZE, KEY_SIZE)
117
+ return middle if other == key
118
+
119
+ other < key ? low = middle + 1 : high = middle - 1
120
+ end
121
+ nil
122
+ end
123
+
124
+ # The latest answer stored this run under +key+, or nil. A run looks its
125
+ # answers up before it stores any, so there are rarely any to look at.
126
+ def stored(key)
127
+ index = @stored_places.size - 1
128
+ while index >= 0
129
+ return index if @stored_keys.byteslice(index * KEY_SIZE, KEY_SIZE) == key
130
+
131
+ index -= 1
132
+ end
133
+ nil
134
+ end
135
+
136
+ def read(file, at)
137
+ JSON.parse(raw(file, at))
138
+ end
139
+
140
+ def raw(file, at)
141
+ file.pread(at & ((1 << SPAN_BITS) - 1), at >> SPAN_BITS)
142
+ end
143
+
144
+ def place(offset, length)
145
+ (offset << SPAN_BITS) | length
146
+ end
147
+
148
+ # Deleted as soon as it's open where the system allows that, so it goes
149
+ # away with the process; elsewhere, once the cache is saved.
150
+ def scratch
151
+ FileUtils.mkdir_p(File.dirname(@path))
152
+ path = "#{@path}.#{Process.pid}.answers"
153
+ file = File.open(path, "w+b")
154
+ begin
155
+ File.delete(path)
156
+ rescue SystemCallError
157
+ @scratch_path = path
158
+ end
159
+ @scratch_size = 0
160
+ file
161
+ end
162
+
163
+ def reload
164
+ @saved = false
165
+ @keys = +""
166
+ @places = []
167
+ @entries = {}
168
+ @stored_keys = +""
169
+ @stored_places = []
170
+ @scratch = nil
171
+ @scratch_path = nil
172
+ load_entries
173
+ end
174
+
175
+ # The file can't be replaced while it's open everywhere.
176
+ def close
177
+ @file&.close
178
+ @scratch&.close
179
+ File.delete(@scratch_path) if @scratch_path
180
+ end
181
+
182
+ # Merges the file's entries, or those read whole, with the answers stored
183
+ # this run, which replace any with the same key.
184
+ def write(out)
185
+ whole = @entries.keys.sort
186
+ kept = whole.empty? ? @places.size : whole.size
187
+ count = 0
188
+ i = 0
189
+ stored_buckets.each do |bucket|
190
+ keys = bucket.map { |index| @stored_keys.byteslice(index * KEY_SIZE, KEY_SIZE) }
191
+ latest(keys).each do |n|
192
+ key = keys[n]
193
+ while i < kept && (mine = kept_key(whole, i)) < key
194
+ count = entry(out, count, mine, kept_text(whole, i))
195
+ i += 1
196
+ end
197
+ i += 1 if i < kept && kept_key(whole, i) == key
198
+ count = entry(out, count, key, raw(@scratch, @stored_places[bucket[n]]))
199
+ end
200
+ end
201
+ while i < kept
202
+ count = entry(out, count, kept_key(whole, i), kept_text(whole, i))
203
+ i += 1
204
+ end
205
+ out.write(count.zero? ? "{}\n" : "\n}\n")
206
+ end
207
+
208
+ # +text+ is the value, indented to sit in the file's object.
209
+ def entry(out, count, key, text)
210
+ out.write(count.zero? ? "{\n " : ",\n ")
211
+ out.write(JSON.generate(key))
212
+ out.write(": ")
213
+ out.write(text)
214
+ count + 1
215
+ end
216
+
217
+ def indented(value)
218
+ JSON.pretty_generate(value).gsub("\n", "\n ")
219
+ end
220
+
221
+ def kept_key(whole, index)
222
+ whole.empty? ? @keys.byteslice(index * KEY_SIZE, KEY_SIZE) : whole[index]
223
+ end
224
+
225
+ def kept_text(whole, index)
226
+ indented(whole.empty? ? read(@file, @places[index]) : @entries[whole[index]])
227
+ end
228
+
229
+ # The answers stored this run, by the first two hex digits of their keys:
230
+ # in key order a bucket at a time, so only one bucket's keys are out at
231
+ # once.
232
+ def stored_buckets
233
+ buckets = Array.new(256) { [] }
234
+ @stored_places.size.times do |index|
235
+ at = index * KEY_SIZE
236
+ buckets[(digit(@stored_keys.getbyte(at)) << 4) | digit(@stored_keys.getbyte(at + 1))] << index
237
+ end
238
+ buckets
239
+ end
240
+
241
+ def digit(byte)
242
+ byte < 97 ? byte - 48 : byte - 87
243
+ end
244
+
245
+ # The indexes of +keys+ in key order, keeping the latest of any stored twice.
246
+ def latest(keys)
247
+ found = []
248
+ (0...keys.size).sort_by { |index| keys[index] }.each do |index|
249
+ if !found.empty? && keys[found.last] == keys[index]
250
+ found[-1] = index if index > found.last
251
+ else
252
+ found << index
253
+ end
254
+ end
255
+ found
256
+ end
257
+
42
258
  def load_entries
43
- return {} unless @path && File.exist?(@path)
259
+ return unless @path && File.exist?(@path)
44
260
 
261
+ @file = File.open(@path, "rb")
262
+ return if index
263
+
264
+ @keys = +""
265
+ @places = []
45
266
  data = JSON.parse(File.read(@path))
46
267
  raise ConfigError, "#{@path} isn't a judgelint cache; delete it to start over" unless data.is_a?(Hash)
47
268
 
48
- data
269
+ @entries = data
49
270
  rescue JSON::ParserError
50
271
  raise ConfigError, "#{@path} isn't valid JSON; delete it to start over"
51
272
  end
273
+
274
+ # Indexes a file in the form #save writes, with its keys in order,
275
+ # checking that each value parses. Lines may end in CRLF, as #save writes
276
+ # them on Windows. False for anything else.
277
+ def index
278
+ offset = 0
279
+ last = nil # the latest key
280
+ open = false # inside a value that spans lines
281
+ start = 0
282
+ plain = false # the open value's lines are all plain so far
283
+ comma = true # the open value's last line ended in a comma
284
+ ended = false # read the entry without a comma
285
+ closed = false # read the final brace
286
+ @file.each_line do |line|
287
+ return false if closed
288
+
289
+ if open && line.start_with?(" ")
290
+ plain &&= BY_PATTERN && comma && line.match?(MEMBER)
291
+ comma = line.end_with?(",\n", ",\r\n")
292
+ offset += line.bytesize
293
+ next
294
+ end
295
+
296
+ bare = line.chomp
297
+ if offset.zero?
298
+ return false unless bare == "{" || bare == "{}"
299
+
300
+ closed = bare == "{}"
301
+ elsif open
302
+ return false unless bare == " }" || bare == " },"
303
+ return false unless indexed(last, start, offset + 3 - start, plain && !comma)
304
+
305
+ open = false
306
+ ended = bare == " }"
307
+ elsif ended
308
+ return false unless bare == "}"
309
+
310
+ closed = true
311
+ else
312
+ match = ENTRY.match(bare)
313
+ return false unless match && (last.nil? || last < match[1])
314
+
315
+ last = match[1]
316
+ rest = match[2]
317
+ start = offset + VALUE_AT
318
+ if rest == "{"
319
+ open = true
320
+ plain = true
321
+ comma = true
322
+ else
323
+ ended = !rest.end_with?(",")
324
+ value = ended ? rest : rest.delete_suffix(",")
325
+ return false unless indexed(last, start, value.bytesize, BY_PATTERN && value.match?(SCALAR), value)
326
+ end
327
+ end
328
+ offset += line.bytesize
329
+ end
330
+ closed
331
+ end
332
+
333
+ # Keeps +key+'s place, once its value is known to parse. +text+ is the
334
+ # value when it's at hand.
335
+ def indexed(key, start, length, plain, text = nil)
336
+ JSON.parse(text || @file.pread(length, start)) unless plain
337
+ @keys << key
338
+ @places << place(start, length)
339
+ true
340
+ rescue JSON::ParserError
341
+ false
342
+ end
52
343
  end
53
344
  end
data/lib/judgelint/cli.rb CHANGED
@@ -160,7 +160,8 @@ module Judgelint
160
160
  else
161
161
  paths = Glob.files(rules.flat_map(&:paths))
162
162
  end
163
- paths.select { |path| rules.any? { |rule| !rule.directory? && rule.applies_to?(path) } }.reject { |path| Glob.binary?(path) }
163
+ buffer = String.new(capacity: 8000)
164
+ paths.select { |path| rules.any? { |rule| !rule.directory? && rule.applies_to?(path) } }.reject { |path| Glob.binary?(path, buffer) }
164
165
  end
165
166
 
166
167
  # The directories directory rules govern; with paths named, those the
@@ -24,6 +24,9 @@ module Judgelint
24
24
  end
25
25
  @usage = usage
26
26
  @sleeper = sleeper
27
+ @question_tokens = {}
28
+ @connections = Queue.new # open, and not in use
29
+ @lock = Mutex.new
27
30
  check_settings
28
31
  end
29
32
 
@@ -44,9 +47,10 @@ module Judgelint
44
47
  JSON.generate(payload(state, questions)).bytesize / 3
45
48
  end
46
49
 
47
- # What one more question adds to a request, without serializing the file again.
50
+ # What one more question adds to a request, without serializing the file
51
+ # again. Files share their questions, so each is measured once.
48
52
  def question_tokens(question)
49
- (JSON.generate(@protocol.render(question)).bytesize + question.key.bytesize + 6) / 3
53
+ @question_tokens[question] ||= (JSON.generate(@protocol.render(question)).bytesize + question.key.bytesize + 6) / 3
50
54
  end
51
55
 
52
56
  def url
@@ -133,21 +137,30 @@ module Judgelint
133
137
  end
134
138
  end
135
139
 
136
- # Answers nil after a connection error that's worth another try.
140
+ # Answers nil after a connection error that's worth another try. The
141
+ # HTTP stack loads with the first request, so a run the cache answers
142
+ # never loads it, and TLS only for an https endpoint. A connection is
143
+ # kept for the next request, from any thread, unless the request on it
144
+ # fails.
137
145
  def request(body, attempt)
138
- uri = URI(url)
139
- http = Net::HTTP.new(uri.host, uri.port)
140
- http.use_ssl = uri.scheme == "https"
141
- http.open_timeout = Endpoint::TIMEOUT
142
- http.read_timeout = Endpoint::TIMEOUT
143
-
146
+ require "net/http"
147
+ uri = @lock.synchronize { @uri ||= URI(url) }
148
+ require "openssl" if uri.scheme == "https"
144
149
  request = Net::HTTP::Post.new(uri.request_uri)
145
150
  request["Content-Type"] = "application/json"
146
151
  request["Accept"] = "application/json"
147
152
  authorize(request)
148
153
  @endpoint.headers.each { |name, value| request[name.to_s] = Config.expand(value.to_s) }
149
154
  request.body = body
150
- http.request(request)
155
+ http = connection(uri)
156
+ begin
157
+ response = http.request(request)
158
+ rescue StandardError
159
+ http.finish if http.started?
160
+ raise
161
+ end
162
+ @connections << http
163
+ response
151
164
  rescue Judgelint::Error
152
165
  raise
153
166
  rescue StandardError => e
@@ -160,6 +173,17 @@ module Judgelint
160
173
  nil
161
174
  end
162
175
 
176
+ # One kept from an earlier request, or a new one.
177
+ def connection(uri)
178
+ @connections.pop(true)
179
+ rescue ThreadError
180
+ http = Net::HTTP.new(uri.host, uri.port)
181
+ http.use_ssl = uri.scheme == "https"
182
+ http.open_timeout = Endpoint::TIMEOUT
183
+ http.read_timeout = Endpoint::TIMEOUT
184
+ http.start
185
+ end
186
+
163
187
  def authorize(request)
164
188
  key = @endpoint.api_key
165
189
  return if key.to_s.empty?
@@ -13,8 +13,9 @@ module Judgelint
13
13
  end
14
14
  end
15
15
 
16
+ # Rules from one spec share their patterns, so each is globbed once.
16
17
  def self.expand(patterns)
17
- patterns.flat_map { |pattern| glob(pattern) }.select { |path| File.file?(path) }.uniq.sort
18
+ patterns.uniq.flat_map { |pattern| glob(pattern) }.select { |path| File.file?(path) }.uniq.sort
18
19
  end
19
20
 
20
21
  # Dir.glob, except that a symlink to itself is skipped. Dir.glob raises
@@ -44,6 +45,7 @@ module Judgelint
44
45
  listed = project_files
45
46
  return expand(patterns) if listed.nil?
46
47
 
48
+ patterns = patterns.uniq
47
49
  listed.select { |path| match_any?(patterns, path) && File.file?(path) }
48
50
  end
49
51
 
@@ -55,9 +57,10 @@ module Judgelint
55
57
  nil
56
58
  end
57
59
 
58
- # A NUL byte in the first 8 KB, as git decides.
59
- def self.binary?(path)
60
- File.open(path, "rb") { |file| file.read(8000).to_s.include?("\0") }
60
+ # A NUL byte in the first 8 KB, as git decides. Every file is checked, so
61
+ # a caller can pass a +buffer+ to read into each time.
62
+ def self.binary?(path, buffer = +"")
63
+ File.open(path, "rb") { |file| file.read(8000, buffer).to_s.include?("\0") }
61
64
  end
62
65
 
63
66
  # Files named on the command line, with directories expanded.
@@ -3,9 +3,12 @@
3
3
  module Judgelint
4
4
  # Maps items over a few threads and returns results in the items' order, so
5
5
  # output doesn't depend on which request came back first. The first error
6
- # is raised once every thread has finished.
6
+ # is raised once every thread has finished. A single item runs on the
7
+ # calling thread.
7
8
  module Pool
8
9
  def self.map(items, size)
10
+ return items.map { |item| yield(item) } if items.size <= 1
11
+
9
12
  results = Array.new(items.size)
10
13
  queue = Queue.new
11
14
  items.each_with_index { |item, index| queue << [item, index] }
@@ -14,6 +14,9 @@ module Judgelint
14
14
  #
15
15
  # A rule written as a question is asked yes or no, as written. Code with a
16
16
  # judgehint comment is also asked, yes or no, whether the hint excuses it.
17
+ #
18
+ # Files share their questions, so a question works out its wording and
19
+ # options once, while the run is planned, and keeps them.
17
20
  class Question
18
21
  BREAKS = "The code clearly breaks the rule."
19
22
  FOLLOWS = "The code follows the rule."
@@ -37,7 +40,7 @@ module Judgelint
37
40
  end
38
41
 
39
42
  def places
40
- @places || (first..last).map { |line| "L#{line}" }
43
+ @places ||= (first..last).map { |line| "L#{line}" }
41
44
  end
42
45
 
43
46
  def excuse?
@@ -54,18 +57,22 @@ module Judgelint
54
57
  end
55
58
 
56
59
  def key
57
- base = "#{rule.question_key}.#{first}-#{last}"
58
- return "#{base}.excused" if excuse?
59
- return "#{base}.where" if where?
60
-
61
- base
60
+ @key ||= begin
61
+ base = "#{rule.question_key}.#{first}-#{last}"
62
+ if excuse? then "#{base}.excused"
63
+ elsif where? then "#{base}.where"
64
+ else base
65
+ end
66
+ end
62
67
  end
63
68
 
64
69
  # A choice's options, as {name => description or nil}.
65
70
  def options
66
- return places.to_h { |place| [place, nil] } if where?
67
-
68
- { "breaks" => rule.yes_means || BREAKS, "follows" => rule.no_means || FOLLOWS, "not_applicable" => NOT_APPLICABLE }
71
+ @options ||= if where?
72
+ places.to_h { |place| [place, nil] }
73
+ else
74
+ { "breaks" => rule.yes_means || BREAKS, "follows" => rule.no_means || FOLLOWS, "not_applicable" => NOT_APPLICABLE }
75
+ end
69
76
  end
70
77
 
71
78
  # The probability the code breaks the rule, from a verdict's answer: a
@@ -79,16 +86,7 @@ module Judgelint
79
86
  # "Answer yes only when..." keeps the model from flagging code the rule
80
87
  # doesn't cover.
81
88
  def instructions
82
- return excuse_instructions if excuse?
83
- return where_instructions if where?
84
- return "How #{rule.scope == 'file' || lines&.size == 1 ? 'does' : 'do'} #{subject} relate to the following team rule? Rule: #{rule.convention}" if rule.convention
85
-
86
- text = rule.window? ? "Look at #{span} of the file; the rest is context. #{rule.question}" : rule.question
87
- return text unless rule.fail_when == "yes"
88
-
89
- scope = { "window" => "those lines do it", "file" => "the code does it", "directory" => "the files do it" }
90
- .fetch(rule.scope)
91
- "#{text} Answer yes only when #{scope}, not when the question doesn't apply."
89
+ @instructions ||= instructions_text
92
90
  end
93
91
 
94
92
  def yes_means
@@ -112,6 +110,19 @@ module Judgelint
112
110
 
113
111
  private
114
112
 
113
+ def instructions_text
114
+ return excuse_instructions if excuse?
115
+ return where_instructions if where?
116
+ return "How #{rule.scope == 'file' || lines&.size == 1 ? 'does' : 'do'} #{subject} relate to the following team rule? Rule: #{rule.convention}" if rule.convention
117
+
118
+ text = rule.window? ? "Look at #{span} of the file; the rest is context. #{rule.question}" : rule.question
119
+ return text unless rule.fail_when == "yes"
120
+
121
+ scope = { "window" => "those lines do it", "file" => "the code does it", "directory" => "the files do it" }
122
+ .fetch(rule.scope)
123
+ "#{text} Answer yes only when #{scope}, not when the question doesn't apply."
124
+ end
125
+
115
126
  def subject
116
127
  { "window" => "#{span} of the file", "file" => "the file", "directory" => "the files" }.fetch(rule.scope)
117
128
  end
@@ -19,7 +19,7 @@ module Judgelint
19
19
 
20
20
  def check(verdicts, config:, file_count:, usage:)
21
21
  case @format
22
- when "json" then json(check_json(verdicts, config, file_count, usage))
22
+ when "json" then json_with(check_json(verdicts, config, file_count, usage), "results", verdicts, &:to_h)
23
23
  when "github" then verdicts.each { |verdict| annotate(verdict) }
24
24
  when "sarif" then json(sarif(verdicts))
25
25
  else text(verdicts, file_count)
@@ -27,7 +27,7 @@ module Judgelint
27
27
  end
28
28
 
29
29
  def fixtures(examples, usage:)
30
- return json(fixtures_json(examples, usage)) if @format == "json"
30
+ return json_with(fixtures_json(examples, usage), "examples", examples, &:to_h) if @format == "json"
31
31
 
32
32
  examples.group_by(&:rule).each do |rule, group|
33
33
  correct = group.count(&:correct?)
@@ -51,6 +51,39 @@ module Judgelint
51
51
  @out.puts JSON.pretty_generate(data)
52
52
  end
53
53
 
54
+ # Prints +data+ with +key+ last, holding +items+ made into hashes, as
55
+ # JSON.pretty_generate does, but a value at a time: a run's results can
56
+ # be many. An item's values are scalars, or lists of them.
57
+ def json_with(data, key, items)
58
+ head = JSON.pretty_generate(data.merge(key => []))
59
+ return @out.puts(head) if items.empty?
60
+
61
+ @out.write("#{head.delete_suffix("[]\n}")}[")
62
+ items.each_with_index do |item, index|
63
+ @out.write(index.zero? ? "\n {" : ",\n {")
64
+ position = 0
65
+ yield(item).each do |name, value|
66
+ @out.write(position.zero? ? "\n " : ",\n ")
67
+ @out.write(JSON.generate(name))
68
+ @out.write(": ")
69
+ json_value(value)
70
+ position += 1
71
+ end
72
+ @out.write("\n }")
73
+ end
74
+ @out.write("\n ]\n}\n")
75
+ end
76
+
77
+ def json_value(value)
78
+ return @out.write(JSON.generate(value)) unless value.is_a?(Array) && !value.empty?
79
+
80
+ value.each_with_index do |element, position|
81
+ @out.write(position.zero? ? "[\n " : ",\n ")
82
+ @out.write(JSON.generate(element))
83
+ end
84
+ @out.write("\n ]")
85
+ end
86
+
54
87
  def text(verdicts, file_count)
55
88
  shown = verdicts.reject { |verdict| verdict.status == :pass }
56
89
  width = shown.map { |verdict| verdict.rule.id.size }.max
@@ -120,8 +153,7 @@ module Judgelint
120
153
  "models" => config.models.map(&:label),
121
154
  "files" => file_count,
122
155
  "summary" => STATUSES.to_h { |status| [status, counts.fetch(status.to_sym, 0)] },
123
- "usage" => usage.to_h,
124
- "results" => verdicts.map(&:to_h)
156
+ "usage" => usage.to_h
125
157
  }
126
158
  end
127
159
 
@@ -130,8 +162,7 @@ module Judgelint
130
162
  "version" => VERSION,
131
163
  "correct" => examples.count(&:correct?),
132
164
  "total" => examples.size,
133
- "usage" => usage.to_h,
134
- "examples" => examples.map(&:to_h)
165
+ "usage" => usage.to_h
135
166
  }
136
167
  end
137
168
 
@@ -72,7 +72,7 @@ module Judgelint
72
72
  end
73
73
 
74
74
  def question_key
75
- id.gsub(/[^A-Za-z0-9_]/, "_")
75
+ @question_key ||= id.gsub(/[^A-Za-z0-9_]/, "_")
76
76
  end
77
77
 
78
78
  def window?
@@ -12,29 +12,78 @@ module Judgelint
12
12
  # average. A rule's verdict is its worst window.
13
13
  class Runner
14
14
  # One file's work: the questions to ask, each model's answers (from the
15
- # cache or the model), and the requests still to send.
15
+ # cache or the model), and the requests still to send. A job the cache
16
+ # answers in full is settled as soon as it's planned, and any other once
17
+ # it's sent.
16
18
  class Job
17
- attr_reader :source, :state_digest, :questions, :answers, :requests, :expected
19
+ attr_reader :source, :state_digest, :questions, :requests, :expected, :verdicts
18
20
  attr_accessor :skipped
19
21
 
20
22
  # The state is the whole file, and a run plans every file before it
21
- # sends one, so it's built when needed rather than kept. Its hash, which
23
+ # sends one, so it's kept only while the job is planned. Its hash, which
22
24
  # every question's cache key needs, is kept.
23
25
  def initialize(source, questions, prose: nil, expected: nil)
24
26
  @source = source
25
27
  @prose = prose
26
- @state_digest = Digest::SHA256.hexdigest(JSON.generate(state))
28
+ @planning = read_state
29
+ @state_digest = Digest::SHA256.hexdigest(JSON.generate(@planning))
27
30
  @questions = questions
28
- @answers = {} # model index => {question key => probability or nil}
31
+ @answers = nil # model index => {question key => probability or nil}, once there are any
29
32
  @requests = []
30
33
  @expected = expected
31
34
  @skipped = false
35
+ @verdicts = nil
32
36
  end
33
37
 
34
- # Rules from a spec read its prose alongside the file, as team_rules.
38
+ # The state to measure or send, which must be the one planned. While
39
+ # the job is planned it's the state that was hashed; after that the
40
+ # file is read again, and one that changed since would be cached under
41
+ # the wrong contents.
35
42
  def state
43
+ return @planning if @planning
44
+
45
+ state = read_state
46
+ return state if Digest::SHA256.hexdigest(JSON.generate(state)) == @state_digest
47
+
48
+ raise changed
49
+ rescue SystemCallError
50
+ raise changed
51
+ end
52
+
53
+ # Planning is done: the state is read again when it's sent.
54
+ def planned
55
+ @planning = nil
56
+ end
57
+
58
+ # A model's answers, or nil for none.
59
+ def answers(model)
60
+ @answers && @answers[model]
61
+ end
62
+
63
+ def answer(model, key, probability)
64
+ @answers ||= {}
65
+ (@answers[model] ||= {})[key] = probability
66
+ end
67
+
68
+ # Keeps the verdicts and lets go of what made them.
69
+ def settle(verdicts)
70
+ @verdicts = verdicts
71
+ @answers = nil
72
+ @source = nil
73
+ @state_digest = nil
74
+ verdicts
75
+ end
76
+
77
+ private
78
+
79
+ # Rules from a spec read its prose alongside the file, as team_rules.
80
+ def read_state
36
81
  @prose ? { "team_rules" => @prose, "file" => @source.state } : @source.state
37
82
  end
83
+
84
+ def changed
85
+ Error.new("#{@source.path} changed while judgelint ran; run it again")
86
+ end
38
87
  end
39
88
 
40
89
  # Questions sent together to one model, and their estimated input tokens.
@@ -73,6 +122,8 @@ module Judgelint
73
122
  @rules = rules
74
123
  @clients = clients
75
124
  @cache = cache
125
+ @asked = {} # rule => {shape => questions}
126
+ @asked_together = {} # the rules' shapes => questions by rule
76
127
  end
77
128
 
78
129
  # +changes+ ({path => lines or :all}, from Changes.since) limits each file
@@ -161,12 +212,12 @@ module Judgelint
161
212
  requests.each_with_index.sum { |request, index| request.tokens * prices[index] / 1_000_000.0 }
162
213
  end
163
214
 
164
- # Returns each job's verdicts, in the jobs' order.
215
+ # Returns each job's verdicts, in the jobs' order. Jobs the cache
216
+ # settled while planning have them already.
165
217
  def execute(jobs)
166
- Pool.map(jobs, Config::THREADS) do |job|
167
- ask(job)
168
- verdicts(job)
169
- end
218
+ @cache.reserve(jobs.sum { |job| job.requests.sum { |request| request.questions.size } })
219
+ Pool.map(jobs.reject(&:verdicts), Config::THREADS) { |job| judge(job) }
220
+ jobs.map(&:verdicts)
170
221
  ensure
171
222
  @cache.save
172
223
  end
@@ -183,23 +234,48 @@ module Judgelint
183
234
 
184
235
  def plan_file(source, rules, lines, expected: nil)
185
236
  questions = {}
237
+ shapes = []
238
+ shared = true
186
239
  rules.each do |rule|
187
240
  ranges = rule.ranges(source, Config::WINDOW_LINES).select { |first, last| touched?(lines, first, last) }
188
241
  next if ranges.empty?
189
242
 
190
- asked = []
191
- ranges.each do |first, last|
192
- asked << Question.new(rule, first, last, lines: changed_lines(rule, lines, first, last))
193
- asked << where(source, rule, first, last) if where?(source, rule, first, last)
194
- asked << Question.new(rule, first, last, kind: :excuse) if source.hinted?(first, last)
243
+ if rule.directory?
244
+ questions[rule] = ask_about(source, rule, ranges, lines)
245
+ shared = false
246
+ else
247
+ shape = shape(source, rule, ranges, lines)
248
+ shapes << "#{@rules.index(rule)}:#{shape}"
249
+ questions[rule] = (@asked[rule] ||= {})[shape] ||= ask_about(source, rule, ranges, lines)
195
250
  end
196
- questions[rule] = asked
197
251
  end
252
+ questions = @asked_together[shapes.join("|")] ||= questions if shared
198
253
  job = Job.new(source, questions, prose: rules.first&.prose, expected: expected)
199
254
  pack(job)
255
+ job.planned
256
+ job.settle(verdicts(job)) if job.requests.empty?
200
257
  job
201
258
  end
202
259
 
260
+ # What decides a rule's questions about a file: its ranges, changed lines
261
+ # and judgehints. Files that match share their questions, since a run
262
+ # holds every file it plans. A directory's are its own.
263
+ def shape(source, rule, ranges, lines)
264
+ ranges.map do |first, last|
265
+ "#{first}-#{last}:#{changed_lines(rule, lines, first, last)&.join(',')}:#{source.hinted?(first, last)}"
266
+ end.join(";")
267
+ end
268
+
269
+ def ask_about(source, rule, ranges, lines)
270
+ asked = []
271
+ ranges.each do |first, last|
272
+ asked << Question.new(rule, first, last, lines: changed_lines(rule, lines, first, last))
273
+ asked << where(source, rule, first, last) if where?(source, rule, first, last)
274
+ asked << Question.new(rule, first, last, kind: :excuse) if source.hinted?(first, last)
275
+ end
276
+ asked
277
+ end
278
+
203
279
  # Where a bullet is broken is asked over the range's lines or the
204
280
  # directory's files, but not for a rule written as a question, or past
205
281
  # what a choice can hold.
@@ -235,12 +311,11 @@ module Judgelint
235
311
  # over a model's limit is skipped: it's reported, never sent.
236
312
  def pack(job)
237
313
  @clients.each_with_index do |client, model|
238
- answers = job.answers[model] = {}
239
314
  pending = []
240
315
  job.questions.each_value do |questions|
241
316
  questions.each do |question|
242
317
  cached = @cache.fetch(cache_key(question, job, client))
243
- cached.nil? ? pending << question : answers[question.key] = cached
318
+ cached.nil? ? pending << question : job.answer(model, question.key, cached)
244
319
  end
245
320
  end
246
321
  next if pending.empty?
@@ -269,6 +344,11 @@ module Judgelint
269
344
  end
270
345
  end
271
346
 
347
+ def judge(job)
348
+ ask(job)
349
+ job.settle(verdicts(job))
350
+ end
351
+
272
352
  # Sends every request at once: a file's packed requests and every model's.
273
353
  def ask(job)
274
354
  return if job.requests.empty?
@@ -282,7 +362,7 @@ module Judgelint
282
362
  request.questions.each do |question|
283
363
  probability = results[index][question.key]
284
364
  @cache.store(cache_key(question, job, client), probability) unless probability.nil?
285
- job.answers[request.model][question.key] = probability
365
+ job.answer(request.model, question.key, probability)
286
366
  end
287
367
  end
288
368
  end
@@ -310,8 +390,9 @@ module Judgelint
310
390
  excuses = []
311
391
  spread = {}
312
392
  asked.each do |question|
313
- job.answers.each_value do |answers|
314
- answer = answers[question.key]
393
+ @clients.each_index do |model|
394
+ answers = job.answers(model)
395
+ answer = answers && answers[question.key]
315
396
  next if answer.nil?
316
397
 
317
398
  if question.excuse?
@@ -18,35 +18,46 @@ module Judgelint
18
18
  new(dir, shown_as: shown_as, files: files, context: context, directory: true)
19
19
  end
20
20
 
21
- # +text+ is the file's contents when it isn't on disk, like an example in a spec.
22
- # Each file is kept as one string, since a run holds every file it plans.
21
+ # +text+ is the file's contents when it isn't on disk, like an example in a
22
+ # spec. A file on disk is read once to plan it, and its first state is
23
+ # built from that read, so the two agree. After that it's read again
24
+ # rather than kept, since a run holds every file it plans.
23
25
  def initialize(path, shown_as: path, files: [path], context: [], directory: false, text: nil)
24
26
  @path = path
25
27
  @shown_as = shown_as
26
28
  @directory = directory
27
- @files = text ? [[path, text]] : files.map { |file| [file, File.read(file)] }
29
+ @files = directory ? files : nil
30
+ @text = text
28
31
  @context = context
29
32
  @line_count = 0
30
- @hints = []
31
- @files.each do |_file, contents|
33
+ @hints = nil
34
+ @read = self.files.map { |file| contents(file) }
35
+ @read.each do |contents|
32
36
  contents.each_line do |line|
33
37
  @line_count += 1
34
- @hints << @line_count if HINT.match?(line)
38
+ (@hints ||= []) << @line_count if HINT.match?(line)
35
39
  end
36
40
  end
37
41
  end
38
42
 
43
+ # A file's source is the file alone.
44
+ def files
45
+ @files || [@path]
46
+ end
47
+
39
48
  def directory?
40
49
  @directory
41
50
  end
42
51
 
43
52
  def state
53
+ read = @read || files.map { |file| contents(file) }
54
+ @read = nil
44
55
  body =
45
56
  if directory?
46
- sections = @files.map { |file, contents| "Path: #{shown(file)}\n\n#{numbered(contents)}" }
57
+ sections = files.each_with_index.map { |file, index| "Path: #{shown(file)}\n\n#{numbered(read[index])}" }
47
58
  "Directory: #{shown_as}\n\n#{sections.join("\n")}"
48
59
  else
49
- "Path: #{shown_as}\n\n#{numbered(@files.first[1])}"
60
+ "Path: #{shown_as}\n\n#{numbered(read[0])}"
50
61
  end
51
62
  return body if @context.empty?
52
63
 
@@ -61,17 +72,29 @@ module Judgelint
61
72
 
62
73
  # The directory's files as [path, the path the model sees].
63
74
  def members
64
- @files.map { |file, _contents| [file, shown(file)] }
75
+ files.map { |file| [file, shown(file)] }
65
76
  end
66
77
 
67
78
  def hinted?(first, last)
68
- directory? ? !@hints.empty? : @hints.any? { |line| line.between?(first, last) }
79
+ return false if @hints.nil?
80
+
81
+ directory? || @hints.any? { |line| line.between?(first, last) }
69
82
  end
70
83
 
71
84
  private
72
85
 
86
+ def contents(file)
87
+ @text || File.read(file)
88
+ end
89
+
73
90
  def numbered(contents)
74
- contents.lines.each_with_index.map { |line, index| "#{index + 1}: #{line.chomp}\n" }.join
91
+ text = +""
92
+ number = 0
93
+ contents.each_line do |line|
94
+ number += 1
95
+ text << number.to_s << ": " << line.chomp << "\n"
96
+ end
97
+ text
75
98
  end
76
99
 
77
100
  # A file's path inside the directory, as the model sees it.
@@ -15,19 +15,25 @@ module Judgelint
15
15
  # is :pass or :fail for files under rules/<id>/pass and rules/<id>/fail.
16
16
  # Not Data.define: Spinel can't compile a Data or Struct in a program that also uses net/https and json.
17
17
  class Verdict
18
- attr_reader :rule, :path, :probability, :status, :lines, :line, :file, :expected
18
+ attr_reader :rule, :path, :probability, :status, :line, :file, :expected
19
19
 
20
+ # A run holds a verdict for every rule and file, so the range is kept as
21
+ # one number: its first line, shifted past its last.
20
22
  def initialize(rule:, path:, probability:, status:, lines: nil, line: nil, file: nil, expected: nil)
21
23
  @rule = rule
22
24
  @path = path
23
25
  @probability = probability
24
26
  @status = status
25
- @lines = lines
27
+ @range = lines && ((lines[0] << 32) | lines[1])
26
28
  @line = line
27
29
  @file = file
28
30
  @expected = expected
29
31
  end
30
32
 
33
+ def lines
34
+ @range && [@range >> 32, @range & 0xffffffff]
35
+ end
36
+
31
37
  def as_example(expected)
32
38
  Verdict.new(rule: rule, path: path, probability: probability, status: status, lines: lines, line: line, file: file,
33
39
  expected: expected)
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Judgelint
4
- VERSION = "0.1.9"
4
+ VERSION = "0.1.10"
5
5
  end
data/lib/judgelint.rb CHANGED
@@ -4,7 +4,6 @@ require "json"
4
4
  require "yaml"
5
5
  require "digest"
6
6
  require "fileutils"
7
- require "net/https"
8
7
  require "open3"
9
8
 
10
9
  require_relative "judgelint/version"
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.9
4
+ version: 0.1.10
5
5
  platform: ruby
6
6
  authors:
7
7
  - JP Camara