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 +4 -4
- data/CHANGELOG.md +13 -0
- data/lib/judgelint/cache.rb +297 -6
- data/lib/judgelint/cli.rb +2 -1
- data/lib/judgelint/client.rb +34 -10
- data/lib/judgelint/glob.rb +7 -4
- data/lib/judgelint/pool.rb +4 -1
- data/lib/judgelint/question.rb +30 -19
- data/lib/judgelint/reporter.rb +37 -6
- data/lib/judgelint/rule.rb +1 -1
- data/lib/judgelint/runner.rb +103 -22
- data/lib/judgelint/source.rb +34 -11
- data/lib/judgelint/verdict.rb +8 -2
- data/lib/judgelint/version.rb +1 -1
- data/lib/judgelint.rb +0 -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: fe98d85a793082f8e0660331fd151d1d169ec16937f7a187f4f30c3e52058c54
|
|
4
|
+
data.tar.gz: 54aefdc8a09b29cf66ead37809cc5b2a78df0758a5753ab3cc2411a2d3d35f1d
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/judgelint/cache.rb
CHANGED
|
@@ -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
|
-
@
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
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
|
data/lib/judgelint/client.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
139
|
-
|
|
140
|
-
|
|
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
|
|
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?
|
data/lib/judgelint/glob.rb
CHANGED
|
@@ -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
|
-
|
|
60
|
-
|
|
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.
|
data/lib/judgelint/pool.rb
CHANGED
|
@@ -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] }
|
data/lib/judgelint/question.rb
CHANGED
|
@@ -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
|
|
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
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
67
|
-
|
|
68
|
-
|
|
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
|
-
|
|
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
|
data/lib/judgelint/reporter.rb
CHANGED
|
@@ -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
|
|
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
|
|
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
|
|
data/lib/judgelint/rule.rb
CHANGED
data/lib/judgelint/runner.rb
CHANGED
|
@@ -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, :
|
|
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
|
|
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
|
-
@
|
|
28
|
+
@planning = read_state
|
|
29
|
+
@state_digest = Digest::SHA256.hexdigest(JSON.generate(@planning))
|
|
27
30
|
@questions = questions
|
|
28
|
-
@answers =
|
|
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
|
-
#
|
|
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
|
-
|
|
167
|
-
|
|
168
|
-
|
|
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
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
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 :
|
|
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.
|
|
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
|
-
|
|
314
|
-
|
|
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?
|
data/lib/judgelint/source.rb
CHANGED
|
@@ -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
|
|
22
|
-
#
|
|
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 =
|
|
29
|
+
@files = directory ? files : nil
|
|
30
|
+
@text = text
|
|
28
31
|
@context = context
|
|
29
32
|
@line_count = 0
|
|
30
|
-
@hints =
|
|
31
|
-
@files.
|
|
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 =
|
|
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(
|
|
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
|
-
|
|
75
|
+
files.map { |file| [file, shown(file)] }
|
|
65
76
|
end
|
|
66
77
|
|
|
67
78
|
def hinted?(first, last)
|
|
68
|
-
|
|
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
|
-
|
|
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.
|
data/lib/judgelint/verdict.rb
CHANGED
|
@@ -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, :
|
|
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
|
-
@
|
|
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)
|
data/lib/judgelint/version.rb
CHANGED
data/lib/judgelint.rb
CHANGED