judgelint 0.1.0
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 +7 -0
- data/CHANGELOG.md +24 -0
- data/LICENSE +21 -0
- data/README.md +225 -0
- data/exe/judgelint +14 -0
- data/lib/judgelint/cache.rb +53 -0
- data/lib/judgelint/changes.rb +87 -0
- data/lib/judgelint/cli.rb +240 -0
- data/lib/judgelint/client.rb +182 -0
- data/lib/judgelint/config.rb +162 -0
- data/lib/judgelint/errors.rb +14 -0
- data/lib/judgelint/glob.rb +31 -0
- data/lib/judgelint/pool.rb +41 -0
- data/lib/judgelint/protocol.rb +88 -0
- data/lib/judgelint/providers.rb +78 -0
- data/lib/judgelint/question.rb +100 -0
- data/lib/judgelint/reporter.rb +147 -0
- data/lib/judgelint/rule.rb +147 -0
- data/lib/judgelint/runner.rb +274 -0
- data/lib/judgelint/scaffold.rb +67 -0
- data/lib/judgelint/source.rb +98 -0
- data/lib/judgelint/spec.rb +189 -0
- data/lib/judgelint/usage.rb +53 -0
- data/lib/judgelint/verdict.rb +61 -0
- data/lib/judgelint/version.rb +5 -0
- data/lib/judgelint.rb +57 -0
- metadata +74 -0
|
@@ -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
|