judgelint 0.1.0-x86_64-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.
@@ -0,0 +1,100 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # One rule asked about one range of a file. The model sees the whole file;
5
+ # the question points at the lines.
6
+ #
7
+ # A spec's bullet is asked twice: whether the code breaks it, and, with the
8
+ # answer flipped, whether the code follows it. Decision models lean toward
9
+ # no on the first and yes on the second, so the average is steadier than
10
+ # either.
11
+ class Question
12
+ BREAKS = "The code clearly breaks the rule."
13
+ FOLLOWS = "The code follows the rule, or the rule doesn't apply to it."
14
+
15
+ attr_reader :rule, :first, :last
16
+
17
+ def initialize(rule, first, last, inverted: false)
18
+ @rule = rule
19
+ @first = first
20
+ @last = last
21
+ @inverted = inverted
22
+ end
23
+
24
+ def key
25
+ base = "#{rule.question_key}.#{first}-#{last}"
26
+ return base unless rule.convention
27
+
28
+ @inverted ? "#{base}.follows" : "#{base}.breaks"
29
+ end
30
+
31
+ # The probability the code breaks the rule, from the probability of yes.
32
+ def violation(yes_probability)
33
+ @inverted ? 1.0 - yes_probability : rule.violation_probability(yes_probability)
34
+ end
35
+
36
+ # "Answer yes only when..." keeps the model from flagging code the rule
37
+ # doesn't cover.
38
+ def instructions
39
+ return convention_instructions if rule.convention
40
+
41
+ text = rule.window? ? "Look at lines #{first}-#{last} of the file; the rest is context. #{rule.question}" : rule.question
42
+ return text unless rule.fail_when == "yes"
43
+
44
+ scope = { "window" => "those lines do it", "file" => "the code does it", "directory" => "the files do it" }
45
+ .fetch(rule.scope)
46
+ "#{text} Answer yes only when #{scope}, not when the question doesn't apply."
47
+ end
48
+
49
+ def yes_means
50
+ return rule.yes_means unless rule.convention
51
+
52
+ @inverted ? rule.no_means || FOLLOWS : rule.yes_means || BREAKS
53
+ end
54
+
55
+ def no_means
56
+ return rule.no_means unless rule.convention
57
+
58
+ @inverted ? rule.yes_means || BREAKS : rule.no_means || FOLLOWS
59
+ end
60
+
61
+ def digest
62
+ @digest ||= Digest::SHA256.hexdigest(JSON.generate([instructions, yes_means.to_s, no_means.to_s]))
63
+ end
64
+
65
+ private
66
+
67
+ # The spec's prose is in the state as team_rules, so the bullet is "the
68
+ # following team rule".
69
+ def convention_instructions
70
+ ask =
71
+ case rule.scope
72
+ when "window"
73
+ if @inverted
74
+ "Do lines #{first}-#{last} of the file follow the following team rule, or does it not apply to them? " \
75
+ "Answer false only when those lines clearly break it."
76
+ else
77
+ "Do lines #{first}-#{last} of the file clearly violate the following team rule? Answer true only " \
78
+ "when the code breaks the rule, not when the rule does not apply to it."
79
+ end
80
+ when "file"
81
+ if @inverted
82
+ "Does the file follow the following team rule, or does it not apply to it? Answer false only when " \
83
+ "the file clearly breaks it."
84
+ else
85
+ "Does the file clearly violate the following team rule? Answer true only when the code breaks the " \
86
+ "rule, not when the rule does not apply to it."
87
+ end
88
+ else
89
+ if @inverted
90
+ "Do the files follow the following team rule, or does it not apply to them? Answer false only when " \
91
+ "they clearly break it."
92
+ else
93
+ "Do the files clearly violate the following team rule? Answer true only when the code breaks the " \
94
+ "rule, not when the rule does not apply to it."
95
+ end
96
+ end
97
+ "#{ask} Rule: #{rule.convention}"
98
+ end
99
+ end
100
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # Prints results as text, JSON, GitHub Actions annotations, or SARIF. Data
5
+ # goes to stdout; anything else goes to stderr.
6
+ class Reporter
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
10
+
11
+ def initialize(out, format:)
12
+ @out = out
13
+ @format = format
14
+ end
15
+
16
+ def check(verdicts, config:, file_count:, usage:)
17
+ case @format
18
+ when "json" then json(check_json(verdicts, config, file_count, usage))
19
+ when "github" then verdicts.each { |verdict| annotate(verdict) }
20
+ when "sarif" then json(sarif(verdicts))
21
+ else text(verdicts, file_count)
22
+ end
23
+ end
24
+
25
+ def fixtures(examples, usage:)
26
+ return json(fixtures_json(examples, usage)) if @format == "json"
27
+
28
+ examples.group_by(&:rule).each do |rule, group|
29
+ correct = group.count(&:correct?)
30
+ @out.puts "#{rule.id} #{correct}/#{group.size}"
31
+ group.each do |example|
32
+ mark = example.correct? ? "ok" : "MISS"
33
+ @out.puts format(" %-4s %-4s %s %s", mark, example.expected, probability(example), example.path)
34
+ end
35
+ end
36
+ @out.puts "" unless examples.empty?
37
+ @out.puts "#{examples.count(&:correct?)}/#{examples.size} examples judged correctly"
38
+ end
39
+
40
+ private
41
+
42
+ def json(data)
43
+ @out.puts JSON.pretty_generate(data)
44
+ end
45
+
46
+ def text(verdicts, file_count)
47
+ shown = verdicts.reject { |verdict| verdict.status == :pass }
48
+ width = shown.map { |verdict| verdict.rule.id.size }.max
49
+ shown.group_by(&:path).each do |path, group|
50
+ @out.puts path
51
+ group.each { |verdict| @out.puts " #{line(verdict, width)}" }
52
+ end
53
+ @out.puts "" unless shown.empty?
54
+ @out.puts summary(verdicts, file_count)
55
+ end
56
+
57
+ def line(verdict, width)
58
+ label = verdict.status == :violation ? verdict.rule.severity : verdict.status.to_s
59
+ format("%s %-10s %-#{width}s %-7s %s %s", MARKS.fetch(verdict.status), label, verdict.rule.id,
60
+ range(verdict), probability(verdict), verdict.rule.message)
61
+ end
62
+
63
+ def range(verdict)
64
+ verdict.lines ? verdict.lines.join("-") : ""
65
+ end
66
+
67
+ def probability(verdict)
68
+ return " - " if verdict.probability.nil?
69
+
70
+ format("%.2f", verdict.probability)
71
+ end
72
+
73
+ def summary(verdicts, file_count)
74
+ counts = verdicts.group_by(&:status).transform_values(&:size)
75
+ parts = ["#{file_count} #{file_count == 1 ? 'file' : 'files'}", "#{counts.fetch(:violation, 0)} violations"]
76
+ parts << "#{counts[:uncertain]} uncertain" if counts[:uncertain]
77
+ parts << "#{counts[:skipped]} skipped (too large)" if counts[:skipped]
78
+ parts << "#{counts[:refused]} refused by the model" if counts[:refused]
79
+ parts << "#{counts[:suppressed]} suppressed" if counts[:suppressed]
80
+ parts.join(", ")
81
+ end
82
+
83
+ def check_json(verdicts, config, file_count, usage)
84
+ counts = verdicts.group_by(&:status).transform_values(&:size)
85
+ {
86
+ "version" => VERSION,
87
+ "models" => config.models.map(&:label),
88
+ "files" => file_count,
89
+ "summary" => STATUSES.to_h { |status| [status, counts.fetch(status.to_sym, 0)] },
90
+ "usage" => usage.to_h,
91
+ "results" => verdicts.map(&:to_h)
92
+ }
93
+ end
94
+
95
+ def fixtures_json(examples, usage)
96
+ {
97
+ "version" => VERSION,
98
+ "correct" => examples.count(&:correct?),
99
+ "total" => examples.size,
100
+ "usage" => usage.to_h,
101
+ "examples" => examples.map(&:to_h)
102
+ }
103
+ end
104
+
105
+ # GitHub Actions workflow commands, which show up on the pull request's lines.
106
+ def annotate(verdict)
107
+ level = { violation: verdict.rule.error? ? "error" : "warning", uncertain: "notice" }[verdict.status]
108
+ return unless level
109
+
110
+ properties = ["file=#{escape(verdict.path, true)}"]
111
+ properties += ["line=#{verdict.lines[0]}", "endLine=#{verdict.lines[1]}"] if verdict.lines
112
+ properties << "title=#{escape("judgelint #{verdict.rule.id}", true)}"
113
+ @out.puts "::#{level} #{properties.join(',')}::#{escape("#{verdict.rule.message} (#{probability(verdict)})", false)}"
114
+ end
115
+
116
+ def escape(text, property)
117
+ escaped = text.gsub("%", "%25").gsub("\r", "%0D").gsub("\n", "%0A")
118
+ property ? escaped.gsub(":", "%3A").gsub(",", "%2C") : escaped
119
+ end
120
+
121
+ def sarif(verdicts)
122
+ shown = verdicts.select { |verdict| %i[violation uncertain].include?(verdict.status) }
123
+ rules = shown.map(&:rule).uniq
124
+ {
125
+ "$schema" => "https://json.schemastore.org/sarif-2.1.0.json",
126
+ "version" => "2.1.0",
127
+ "runs" => [{
128
+ "tool" => { "driver" => {
129
+ "name" => "judgelint", "version" => VERSION, "informationUri" => "https://github.com/jpcamara/judgelint",
130
+ "rules" => rules.map { |rule| { "id" => rule.id, "shortDescription" => { "text" => rule.message } } }
131
+ } },
132
+ "results" => shown.map { |verdict| sarif_result(verdict) }
133
+ }]
134
+ }
135
+ end
136
+
137
+ def sarif_result(verdict)
138
+ region = verdict.lines ? { "startLine" => verdict.lines[0], "endLine" => verdict.lines[1] } : {}
139
+ level = verdict.status == :uncertain ? "note" : (verdict.rule.error? ? "error" : "warning")
140
+ {
141
+ "ruleId" => verdict.rule.id, "level" => level,
142
+ "message" => { "text" => "#{verdict.rule.message} (#{probability(verdict)})" },
143
+ "locations" => [{ "physicalLocation" => { "artifactLocation" => { "uri" => verdict.path }, "region" => region } }]
144
+ }
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,147 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # One convention, asked of a file as a single yes/no question. Rules are
5
+ # markdown files with YAML frontmatter:
6
+ #
7
+ # ---
8
+ # question: Does this code rescue an exception and carry on as if nothing happened?
9
+ # yes_means: The rescue neither logs, re-raises, nor returns an explicit failure.
10
+ # no_means: Every rescue logs, re-raises, or turns the error into a result.
11
+ # paths: ["app/**/*.rb", "lib/**/*.rb"]
12
+ # ---
13
+ # Swallowed exceptions hide bugs until they cost us data.
14
+ #
15
+ # The body is the rationale people read. The model sees only the question
16
+ # and the meanings of yes and no. +fail_when+ is the answer that means the
17
+ # file breaks the convention, so a rule can ask "Does this rescue and discard
18
+ # an exception?" (fail on yes) or "Does this service validate its input
19
+ # first?" (fail on no).
20
+ #
21
+ # +scope+ "window" asks about each range of lines, so a finding says where
22
+ # it is; "file" asks once about the whole file, for conventions no range can
23
+ # show on its own; "directory" asks once about each directory its paths
24
+ # match, with all of the directory's files, for conventions about how files
25
+ # relate. A fail_when: no rule is about the whole file by default.
26
+ #
27
+ # +context+ globs name files the model reads alongside, such as shared
28
+ # types, which are never judged themselves.
29
+ class Rule
30
+ FRONTMATTER = /\A---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|\z)(.*)\z/m
31
+ KEYS = %w[id question convention yes_means no_means fail_when scope paths excludes context severity threshold message].freeze
32
+
33
+ attr_reader :id, :question, :convention, :yes_means, :no_means, :fail_when, :scope, :paths, :excludes,
34
+ :context, :severity, :threshold, :message, :source, :examples_dir, :prose
35
+
36
+ # The rules in +dir+ alone (Spec.discover also finds READMEs).
37
+ def self.load_dir(dir, default_threshold:)
38
+ Spec.discover(rules_dir: dir, default_threshold: default_threshold, readmes: false)
39
+ end
40
+
41
+ # A rule is a question asked exactly as written, or a convention (a
42
+ # spec's bullet) asked whether the code breaks it.
43
+ # +prose+ is a spec's body, which the model reads alongside the file.
44
+ def initialize(data, source:, body: "", default_threshold: 0.7, examples_dir: nil, prose: nil)
45
+ unknown = data.keys - KEYS
46
+ raise ConfigError, "#{source}: unknown keys #{unknown.join(', ')}" unless unknown.empty?
47
+
48
+ @source = source
49
+ @id = (data["id"] || File.basename(source, ".md")).to_s
50
+ @question = text(data["question"])
51
+ @convention = text(data["convention"])
52
+ raise ConfigError, "#{source}: a rule needs a question" if @question.nil? && @convention.nil?
53
+
54
+ @examples_dir = examples_dir || source.delete_suffix(".md")
55
+ @prose = prose
56
+ @yes_means = text(data["yes_means"])
57
+ @no_means = text(data["no_means"])
58
+ @fail_when = one_of("fail_when", yes_or_no(data.fetch("fail_when", "yes")), %w[yes no])
59
+ @scope = one_of("scope", data.fetch("scope", @fail_when == "yes" ? "window" : "file"), %w[window file directory])
60
+ @severity = one_of("severity", data.fetch("severity", "error"), %w[error warning])
61
+ @paths = list(data["paths"], ["**/*"])
62
+ @excludes = list(data["excludes"], [])
63
+ @context = list(data["context"], [])
64
+ @threshold = parse_threshold(data.key?("threshold") ? data["threshold"] : default_threshold)
65
+ @message = (data["message"] || first_paragraph(body) || @question || @convention).to_s.strip
66
+ end
67
+
68
+ def applies_to?(path)
69
+ Glob.match_any?(paths, path) && !Glob.match_any?(excludes, path)
70
+ end
71
+
72
+ def question_key
73
+ id.gsub(/[^A-Za-z0-9_]/, "_")
74
+ end
75
+
76
+ def window?
77
+ scope == "window"
78
+ end
79
+
80
+ def directory?
81
+ scope == "directory"
82
+ end
83
+
84
+ def context_files
85
+ Glob.expand(context)
86
+ end
87
+
88
+ # The directories a directory rule governs.
89
+ def directories
90
+ paths.flat_map { |pattern| Dir.glob(pattern) }.select { |path| File.directory?(path) && applies_to?(path) }.uniq.sort
91
+ end
92
+
93
+ # The ranges to ask about: windows of +size+ lines, or the whole file.
94
+ def ranges(source, size)
95
+ window? ? source.windows(size) : [[1, source.line_count]].reject { |_first, last| last.zero? }
96
+ end
97
+
98
+ def violation_probability(yes_probability)
99
+ fail_when == "yes" ? yes_probability : 1.0 - yes_probability
100
+ end
101
+
102
+ def error?
103
+ severity == "error"
104
+ end
105
+
106
+ private
107
+
108
+ # YAML reads a bare yes/no as true/false.
109
+ def yes_or_no(value)
110
+ return "yes" if value == true
111
+ return "no" if value == false
112
+
113
+ value
114
+ end
115
+
116
+ def one_of(key, value, allowed)
117
+ value = value.to_s
118
+ raise ConfigError, "#{source}: #{key} is #{allowed.join(' or ')}, not #{value}" unless allowed.include?(value)
119
+
120
+ value
121
+ end
122
+
123
+ def parse_threshold(value)
124
+ number = Judgelint.number(value)
125
+ raise ConfigError, "#{source}: threshold must be a number, not #{value}" if number.nil?
126
+ raise ConfigError, "#{source}: threshold must be between 0 and 1" unless number.positive? && number <= 1
127
+
128
+ number
129
+ end
130
+
131
+ def list(value, default)
132
+ return default if value.nil?
133
+
134
+ value.is_a?(Array) ? value.map(&:to_s) : [value.to_s]
135
+ end
136
+
137
+ def text(value)
138
+ string = value.to_s.strip
139
+ string.empty? ? nil : string
140
+ end
141
+
142
+ def first_paragraph(body)
143
+ paragraph = body.split(/\n\s*\n/).map(&:strip).find { |part| !part.empty? && !part.start_with?("#") }
144
+ paragraph&.gsub(/\s*\n\s*/, " ")
145
+ end
146
+ end
147
+ end
@@ -0,0 +1,274 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Judgelint
4
+ # Judges files against the rules that govern them, and rules against their
5
+ # examples, in two steps: plan every request first, so a run can be priced
6
+ # and refused before anything is sent, then send them.
7
+ #
8
+ # Each file's requests carry the whole numbered file as state and one
9
+ # question per rule and window, so a file's verdicts never depend on which
10
+ # other files ran alongside it. Every model is asked every question, all of a
11
+ # file's requests go out at once, and a question's answer is the models'
12
+ # average. A rule's verdict is its worst window.
13
+ class Runner
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.
16
+ class Job
17
+ attr_reader :source, :state, :state_digest, :questions, :suppressed, :answers, :requests, :expected
18
+ attr_accessor :skipped
19
+
20
+ # The state is built and hashed once: it's the whole file, and every
21
+ # question's cache key needs its hash. Rules from a spec read its prose
22
+ # alongside the file, as team_rules.
23
+ def initialize(source, questions, suppressed, prose: nil, expected: nil)
24
+ @source = source
25
+ @state = prose ? { "team_rules" => prose, "file" => source.state } : source.state
26
+ @state_digest = Digest::SHA256.hexdigest(JSON.generate(@state))
27
+ @questions = questions
28
+ @suppressed = suppressed
29
+ @answers = {} # model index => {question key => probability or nil}
30
+ @requests = []
31
+ @expected = expected
32
+ @skipped = false
33
+ end
34
+ end
35
+
36
+ # Questions sent together to one model, and their estimated input tokens.
37
+ class Request
38
+ attr_reader :model, :questions, :tokens
39
+
40
+ def initialize(model, questions, tokens)
41
+ @model = model
42
+ @questions = questions
43
+ @tokens = tokens
44
+ end
45
+ end
46
+
47
+ # +clients+ has one Client per model in config.models.
48
+ def initialize(config:, rules:, clients:, cache:)
49
+ @config = config
50
+ @rules = rules
51
+ @clients = clients
52
+ @cache = cache
53
+ end
54
+
55
+ # +changes+ ({path => lines or :all}, from Changes.since) limits each file
56
+ # to the windows a change touched, and directories to those with a change.
57
+ # Rules that read different context files can't share a request, so a
58
+ # file gets one job per set of context files.
59
+ def plan(paths, directories: [], changes: nil)
60
+ files = paths.flat_map do |path|
61
+ rules = @rules.select { |rule| !rule.directory? && rule.applies_to?(path) }
62
+ by_context(rules) { |context, group| plan_file(Source.new(path, context: context), group, changes ? changes[path] : :all) }
63
+ end
64
+ units = directories.flat_map do |dir|
65
+ next [] if changes && changes.keys.none? { |path| path.start_with?("#{dir}/") }
66
+
67
+ rules = @rules.select { |rule| rule.directory? && rule.applies_to?(dir) }
68
+ by_context(rules) { |context, group| plan_file(Source.directory(dir, context: context), group, :all) }
69
+ end
70
+ files + units
71
+ end
72
+
73
+ attr_reader :untested
74
+
75
+ # Files under <examples>/pass must not be judged violations, files under
76
+ # <examples>/fail must. The model sees each example's path relative to its
77
+ # pass/ or fail/ directory, so the directory never gives the answer away.
78
+ # A rule with examples needs both kinds, checked before anything is sent;
79
+ # rules with none are listed in +untested+.
80
+ def plan_examples
81
+ jobs = []
82
+ missing = []
83
+ @untested = []
84
+ @rules.each do |rule|
85
+ found = %i[pass fail].to_h { |expected| [expected, example_paths(rule, expected)] }
86
+ if found.values.all?(&:empty?)
87
+ @untested << rule
88
+ next
89
+ end
90
+
91
+ found.each do |expected, paths|
92
+ dir = File.join(rule.examples_dir, expected.to_s)
93
+ missing << "#{dir}/" if paths.empty?
94
+ paths.each do |path|
95
+ shown = path.delete_prefix("#{dir}/")
96
+ context = rule.context_files
97
+ source = rule.directory? ? Source.directory(path, shown_as: shown, context: context)
98
+ : Source.new(path, shown_as: shown, context: context)
99
+ jobs << plan_file(source, [rule], :all, expected: expected)
100
+ end
101
+ end
102
+ end
103
+ raise ConfigError, "Examples need both kinds: add some to #{missing.join(', ')}" unless missing.empty?
104
+
105
+ jobs
106
+ end
107
+
108
+ def request_count(jobs)
109
+ jobs.sum { |job| job.requests.size }
110
+ end
111
+
112
+ def estimated_tokens(jobs)
113
+ jobs.sum { |job| job.requests.sum(&:tokens) }
114
+ end
115
+
116
+ # Nil when a model has no price.
117
+ def estimated_cost(jobs)
118
+ requests = jobs.flat_map(&:requests)
119
+ prices = requests.map { |request| @clients[request.model].endpoint.price_per_million }
120
+ return nil if prices.include?(nil)
121
+
122
+ requests.each_with_index.sum { |request, index| request.tokens * prices[index] / 1_000_000.0 }
123
+ end
124
+
125
+ # Returns each job's verdicts, in the jobs' order.
126
+ def execute(jobs)
127
+ Pool.map(jobs, Config::THREADS) do |job|
128
+ ask(job)
129
+ verdicts(job)
130
+ end
131
+ ensure
132
+ @cache.save
133
+ end
134
+
135
+ private
136
+
137
+ # A directory rule's examples are the directories under pass/ and fail/.
138
+ def example_paths(rule, expected)
139
+ dir = File.join(rule.examples_dir, expected.to_s)
140
+ return Dir.glob(File.join(dir, "*")).select { |path| File.directory?(path) }.sort if rule.directory?
141
+
142
+ Glob.expand([File.join(dir, "**", "*")])
143
+ end
144
+
145
+ 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
+ questions = {}
150
+ suppressed = []
151
+ rules.each do |rule|
152
+ ranges = rule.ranges(source, Config::WINDOW_LINES).select { |first, last| touched?(lines, first, last) }
153
+ next if ranges.empty?
154
+
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
165
+ end
166
+ end
167
+ job = Job.new(source, questions, suppressed, prose: rules.first&.prose, expected: expected)
168
+ pack(job)
169
+ job
170
+ end
171
+
172
+ # Rules that read different context files or spec prose can't share a request.
173
+ def by_context(rules)
174
+ rules.group_by { |rule| [rule.context, rule.prose] }.map { |(globs, _prose), group| yield(Glob.expand(globs), group) }
175
+ end
176
+
177
+ def touched?(lines, first, last)
178
+ lines == :all || (lines || []).any? { |line| line.between?(first, last) }
179
+ end
180
+
181
+ # For each model, packs the questions its cache can't answer into as few
182
+ # requests as fit its limits. A file whose state and widest question are
183
+ # over a model's limit is skipped: it's reported, never sent.
184
+ def pack(job)
185
+ @clients.each_with_index do |client, model|
186
+ answers = job.answers[model] = {}
187
+ pending = []
188
+ job.questions.each_value do |questions|
189
+ questions.each do |question|
190
+ cached = @cache.fetch(cache_key(question, job, client))
191
+ cached.nil? ? pending << question : answers[question.key] = cached
192
+ end
193
+ end
194
+ next if pending.empty?
195
+
196
+ limits = client.endpoint
197
+ base = client.tokens(job.state, [])
198
+ sizes = pending.map { |question| client.question_tokens(question) }
199
+ if base + sizes.max >= limits.max_state_tokens
200
+ job.skipped = true
201
+ job.requests.clear
202
+ return
203
+ end
204
+
205
+ batch = []
206
+ tokens = base
207
+ pending.each_with_index do |question, index|
208
+ if !batch.empty? && (tokens + sizes[index] > limits.max_request_tokens || batch.size >= limits.batch_size)
209
+ job.requests << Request.new(model, batch, tokens)
210
+ batch = []
211
+ tokens = base
212
+ end
213
+ batch << question
214
+ tokens += sizes[index]
215
+ end
216
+ job.requests << Request.new(model, batch, tokens)
217
+ end
218
+ end
219
+
220
+ # Sends every request at once: a file's packed requests and every model's.
221
+ def ask(job)
222
+ state = job.state.dup # Spinel can't yet pass the Job's shared String along
223
+ results = Pool.map(job.requests, job.requests.size) do |request|
224
+ @clients[request.model].judge(state, request.questions)
225
+ end
226
+ job.requests.each_with_index do |request, index|
227
+ client = @clients[request.model]
228
+ request.questions.each do |question|
229
+ probability = results[index][question.key]
230
+ @cache.store(cache_key(question, job, client), probability) unless probability.nil?
231
+ job.answers[request.model][question.key] = probability
232
+ end
233
+ end
234
+ end
235
+
236
+ def verdicts(job)
237
+ path = job.source.path
238
+ 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
+ job.expected ? found.map { |verdict| verdict.as_example(job.expected) } : found
241
+ end
242
+
243
+ # A window's probability of a violation is the average over its questions
244
+ # (a bullet is asked both ways) and the models; a rule's verdict is its
245
+ # worst window.
246
+ def verdict_for(rule, path, questions, job)
247
+ return Verdict.new(rule: rule, path: path, probability: nil, status: :skipped) if job.skipped
248
+
249
+ windows = []
250
+ questions.group_by { |question| [question.first, question.last] }.each do |(first, last), asked|
251
+ values = []
252
+ asked.each do |question|
253
+ job.answers.each_value do |answers|
254
+ yes = answers[question.key]
255
+ values << question.violation(yes) unless yes.nil?
256
+ end
257
+ end
258
+ windows << [first, last, values.sum / values.size] unless values.empty?
259
+ end
260
+ return Verdict.new(rule: rule, path: path, probability: nil, status: :refused) if windows.empty?
261
+
262
+ first, last, probability = windows.max_by { |window| window[2] }
263
+ lines = rule.directory? ? nil : [first, last]
264
+ Verdict.classify(rule, path, probability, warn_at: @config.warn_at, lines: lines)
265
+ end
266
+
267
+ # Each model's answers are cached on their own, so adding a model asks
268
+ # only the new one.
269
+ def cache_key(question, job, client)
270
+ endpoint = client.endpoint
271
+ Cache.key(question.digest, job.state_digest, "#{endpoint.provider}/#{endpoint.model}")
272
+ end
273
+ end
274
+ end