jitcatch 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/LICENSE +21 -0
- data/README.ja.md +1052 -0
- data/README.md +529 -0
- data/bin/jitcatch +16 -0
- data/lib/jitcatch/assessor.rb +502 -0
- data/lib/jitcatch/changes.rb +1084 -0
- data/lib/jitcatch/cli.rb +201 -0
- data/lib/jitcatch/diff_parser.rb +59 -0
- data/lib/jitcatch/factories.rb +164 -0
- data/lib/jitcatch/failure.rb +86 -0
- data/lib/jitcatch/framework.rb +110 -0
- data/lib/jitcatch/git_repo.rb +180 -0
- data/lib/jitcatch/inflections.rb +142 -0
- data/lib/jitcatch/minitest_parser.rb +110 -0
- data/lib/jitcatch/minitest_report.rb +54 -0
- data/lib/jitcatch/readers.rb +907 -0
- data/lib/jitcatch/render.rb +712 -0
- data/lib/jitcatch/routes.rb +288 -0
- data/lib/jitcatch/rspec.rb +9 -0
- data/lib/jitcatch/rspec_parser.rb +122 -0
- data/lib/jitcatch/runner.rb +905 -0
- data/lib/jitcatch/source_index.rb +1382 -0
- data/lib/jitcatch/template.rb +38 -0
- data/lib/jitcatch/version.rb +5 -0
- data/lib/jitcatch.rb +96 -0
- data/skills/jitcatch/SKILL.md +244 -0
- metadata +73 -0
|
@@ -0,0 +1,180 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "open3"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
|
|
6
|
+
module JitCatch
|
|
7
|
+
# Thin git wrapper. Every invocation is an argv array, never a shell.
|
|
8
|
+
class GitRepo
|
|
9
|
+
attr_reader :dir
|
|
10
|
+
|
|
11
|
+
def initialize(path)
|
|
12
|
+
@dir = File.expand_path(path)
|
|
13
|
+
raise UsageError, "not a directory: #{path}" unless File.directory?(@dir)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def rev(ref)
|
|
17
|
+
git("rev-parse", "--verify", "--end-of-options", "#{ref}^{commit}").strip
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# `ROOT^` names nothing in git. Reading it as the empty tree lets a
|
|
21
|
+
# review of the first commit use the same BASE HEAD form as any other.
|
|
22
|
+
def base_rev(ref)
|
|
23
|
+
rev(ref)
|
|
24
|
+
rescue GitError
|
|
25
|
+
child = ref.to_s[/\A(.+?)(?:\^1?|~1?)\z/, 1]
|
|
26
|
+
raise unless child && root_commit?(rev(child))
|
|
27
|
+
|
|
28
|
+
empty_tree
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def empty_tree?(sha)
|
|
32
|
+
sha == empty_tree
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def diff(base, head)
|
|
36
|
+
git(
|
|
37
|
+
"diff", "--unified=0", "--no-color", "--no-ext-diff", "--no-renames",
|
|
38
|
+
"--end-of-options", base, head
|
|
39
|
+
)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def name_status(base, head)
|
|
43
|
+
git("diff", "--name-status", "--no-renames", "--end-of-options", base, head)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Oldest first. %(trailers) prints the block git itself parses as trailers,
|
|
47
|
+
# verbatim, so cutting it off the end of %B leaves the message without it.
|
|
48
|
+
def commit_messages(base, head)
|
|
49
|
+
range = empty_tree?(base) ? head : "#{base}..#{head}"
|
|
50
|
+
raw = git("log", "--reverse", "--format=%B%x1f%(trailers)%x1e", "--end-of-options", range)
|
|
51
|
+
raw.split("\x1e").filter_map do |record|
|
|
52
|
+
message, trailers = record.split("\x1f", 2)
|
|
53
|
+
next if trailers.nil?
|
|
54
|
+
|
|
55
|
+
message = message.rstrip
|
|
56
|
+
block = trailers.rstrip
|
|
57
|
+
block.empty? ? message : message.delete_suffix(block)
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The commits only base has, which `diff BASE HEAD` undoes, and the
|
|
62
|
+
# merge base `log BASE..HEAD` starts from. Nil when base is an ancestor.
|
|
63
|
+
def divergence(base, head)
|
|
64
|
+
return nil if empty_tree?(base)
|
|
65
|
+
|
|
66
|
+
_stdout, _stderr, status = capture("merge-base", "--is-ancestor", "--end-of-options", base, head)
|
|
67
|
+
return nil if status.success?
|
|
68
|
+
|
|
69
|
+
count = git("rev-list", "--count", "--end-of-options", "#{head}..#{base}").strip.to_i
|
|
70
|
+
fork = capture("merge-base", "--end-of-options", base, head).first.strip
|
|
71
|
+
{ "base_only" => count, "merge_base" => fork.empty? ? nil : fork }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
Blob = Data.define(:path, :oid, :size)
|
|
75
|
+
|
|
76
|
+
# A symlink is a blob that holds the path it points at, not Ruby. Its
|
|
77
|
+
# target is listed on its own when the repository holds it.
|
|
78
|
+
SYMLINK_MODE = "120000"
|
|
79
|
+
|
|
80
|
+
# The Ruby files and the ERB templates at ref.
|
|
81
|
+
# `-z` leaves a path with a tab, a newline, or a quote unquoted too.
|
|
82
|
+
def source_blobs(ref)
|
|
83
|
+
git("ls-tree", "-r", "-l", "-z", "--end-of-options", ref).split("\0").filter_map { |entry|
|
|
84
|
+
meta, path = entry.split("\t", 2)
|
|
85
|
+
mode, type, oid, size = meta.split
|
|
86
|
+
next unless type == "blob" && mode != SYMLINK_MODE && (path.end_with?(".rb") || Template.template?(path))
|
|
87
|
+
|
|
88
|
+
Blob.new(path: path, oid: oid, size: size.to_i)
|
|
89
|
+
}.sort_by(&:path)
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# One `cat-file` reads every blob. A `git show` per file cost one process
|
|
93
|
+
# each, which was most of the time `context` took.
|
|
94
|
+
def read_blobs(oids)
|
|
95
|
+
return {} if oids.empty?
|
|
96
|
+
|
|
97
|
+
stdout, stderr, status = Open3.capture3(
|
|
98
|
+
"git", "--no-pager", "-C", @dir, "cat-file", "--batch",
|
|
99
|
+
stdin_data: "#{oids.join("\n")}\n", binmode: true
|
|
100
|
+
)
|
|
101
|
+
raise GitError, "git cat-file failed: #{stderr.strip}" unless status.success?
|
|
102
|
+
|
|
103
|
+
blobs = {}
|
|
104
|
+
offset = 0
|
|
105
|
+
while (newline = stdout.index("\n", offset))
|
|
106
|
+
oid, _type, size = stdout.byteslice(offset, newline - offset).split
|
|
107
|
+
blobs[oid] = as_utf8(stdout.byteslice(newline + 1, size.to_i))
|
|
108
|
+
offset = newline + size.to_i + 2
|
|
109
|
+
end
|
|
110
|
+
blobs
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def show(ref, path)
|
|
114
|
+
stdout, _stderr, status = capture("show", "--end-of-options", "#{ref}:#{path}")
|
|
115
|
+
status.success? ? stdout : nil
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
# The parts of the Ruby files at ref that match pattern, an extended
|
|
119
|
+
# regexp, one per line. git grep exits 1 when nothing matches.
|
|
120
|
+
def ruby_matches(ref, pattern)
|
|
121
|
+
stdout, _stderr, status = capture("grep", "-h", "-o", "-I", "-E", "-e", pattern, ref, "--", "*.rb")
|
|
122
|
+
status.success? ? stdout : ""
|
|
123
|
+
end
|
|
124
|
+
|
|
125
|
+
def worktree_add(path, sha)
|
|
126
|
+
git("worktree", "add", "--detach", "--quiet", "--end-of-options", path, sha)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def worktree_remove(path)
|
|
130
|
+
return unless File.directory?(path)
|
|
131
|
+
|
|
132
|
+
_stdout, _stderr, status = capture("worktree", "remove", "--force", "--end-of-options", path)
|
|
133
|
+
FileUtils.remove_entry(path) if !status.success? && File.directory?(path)
|
|
134
|
+
end
|
|
135
|
+
|
|
136
|
+
def worktree_prune
|
|
137
|
+
capture("worktree", "prune")
|
|
138
|
+
end
|
|
139
|
+
|
|
140
|
+
def worktree_list
|
|
141
|
+
git("worktree", "list", "--porcelain")
|
|
142
|
+
end
|
|
143
|
+
|
|
144
|
+
def git(*args, env: nil)
|
|
145
|
+
stdout, stderr, status = capture(*args, env: env)
|
|
146
|
+
return stdout if status.success?
|
|
147
|
+
|
|
148
|
+
detail = stderr.strip
|
|
149
|
+
detail = stdout.strip if detail.empty?
|
|
150
|
+
raise GitError, "git #{args.first} failed: #{detail}"
|
|
151
|
+
end
|
|
152
|
+
|
|
153
|
+
private
|
|
154
|
+
|
|
155
|
+
def root_commit?(sha)
|
|
156
|
+
git("rev-list", "--parents", "-n", "1", "--end-of-options", sha).split.size == 1
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
def empty_tree
|
|
160
|
+
@empty_tree ||= git("hash-object", "-t", "tree", "--end-of-options", File::NULL).strip
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# git quotes a path with non-ASCII bytes by default, and a quoted path
|
|
164
|
+
# names no file in `show` and matches no hunk path.
|
|
165
|
+
def capture(*args, env: nil)
|
|
166
|
+
command = ["git", "--no-pager", "-c", "core.quotePath=false", "-C", @dir, *args]
|
|
167
|
+
merged = ENV.to_h.merge(env || {})
|
|
168
|
+
stdout, stderr, status = Open3.capture3(merged, *command)
|
|
169
|
+
[as_utf8(stdout), as_utf8(stderr), status]
|
|
170
|
+
end
|
|
171
|
+
|
|
172
|
+
# Open3 tags output with the locale's encoding, which is US-ASCII under
|
|
173
|
+
# LANG=C. Paths, sources, and messages in a repository are UTF-8 bytes,
|
|
174
|
+
# and a stray invalid byte must not stop a regexp match.
|
|
175
|
+
def as_utf8(text)
|
|
176
|
+
text.force_encoding(Encoding::UTF_8)
|
|
177
|
+
text.valid_encoding? ? text : text.scrub("?")
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module JitCatch
|
|
6
|
+
# The English inflections Active Support starts from, and the literal
|
|
7
|
+
# `irregular`, `uncountable`, `singular`, `plural`, and `acronym` calls of
|
|
8
|
+
# an app's `config/initializers/inflections.rb`, read as source, so a
|
|
9
|
+
# route's helper and controller take the names Rails gives them
|
|
10
|
+
# (`resources :people` draws `person_path`, `inflect.acronym "API"` makes
|
|
11
|
+
# `APIKeysController`). A rule built at run time is not read.
|
|
12
|
+
class Inflections
|
|
13
|
+
def self.read(source)
|
|
14
|
+
inflections = rails
|
|
15
|
+
result = source ? Prism.parse(source) : nil
|
|
16
|
+
inflections.apply_calls(result.value) if result&.success?
|
|
17
|
+
inflections
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
# Active Support's own list, applied as an app's file would be, in the
|
|
21
|
+
# same order, so a later rule takes precedence as it does there.
|
|
22
|
+
def self.rails
|
|
23
|
+
inflections = new
|
|
24
|
+
[
|
|
25
|
+
[/$/, "s"], [/s$/i, "s"], [/^(ax|test)is$/i, '\1es'], [/(octop|vir)us$/i, '\1i'], [/(octop|vir)i$/i, '\1i'],
|
|
26
|
+
[/(alias|status)$/i, '\1es'], [/(bu)s$/i, '\1ses'], [/(buffal|tomat)o$/i, '\1oes'], [/([ti])um$/i, '\1a'],
|
|
27
|
+
[/([ti])a$/i, '\1a'], [/sis$/i, "ses"], [/(?:([^f])fe|([lr])f)$/i, '\1\2ves'], [/(hive)$/i, '\1s'],
|
|
28
|
+
[/([^aeiouy]|qu)y$/i, '\1ies'], [/(x|ch|ss|sh)$/i, '\1es'], [/(matr|vert|ind)(?:ix|ex)$/i, '\1ices'],
|
|
29
|
+
[/^(m|l)ouse$/i, '\1ice'], [/^(m|l)ice$/i, '\1ice'], [/^(ox)$/i, '\1en'], [/^(oxen)$/i, '\1'], [/(quiz)$/i, '\1zes']
|
|
30
|
+
].each { |rule, replacement| inflections.plural(rule, replacement) }
|
|
31
|
+
[
|
|
32
|
+
[/s$/i, ""], [/(ss)$/i, '\1'], [/(n)ews$/i, '\1ews'], [/([ti])a$/i, '\1um'],
|
|
33
|
+
[/((a)naly|(b)a|(d)iagno|(p)arenthe|(p)rogno|(s)ynop|(t)he)(sis|ses)$/i, '\1sis'], [/(^analy)(sis|ses)$/i, '\1sis'],
|
|
34
|
+
[/([^f])ves$/i, '\1fe'], [/(hive)s$/i, '\1'], [/(tive)s$/i, '\1'], [/([lr])ves$/i, '\1f'], [/([^aeiouy]|qu)ies$/i, '\1y'],
|
|
35
|
+
[/(s)eries$/i, '\1eries'], [/(m)ovies$/i, '\1ovie'], [/(x|ch|ss|sh)es$/i, '\1'], [/^(m|l)ice$/i, '\1ouse'],
|
|
36
|
+
[/(bus)(es)?$/i, '\1'], [/(o)es$/i, '\1'], [/(shoe)s$/i, '\1'], [/(cris|test)(is|es)$/i, '\1is'], [/^(a)x[ie]s$/i, '\1xis'],
|
|
37
|
+
[/(octop|vir)(us|i)$/i, '\1us'], [/(alias|status)(es)?$/i, '\1'], [/^(ox)en/i, '\1'], [/(vert|ind)ices$/i, '\1ex'],
|
|
38
|
+
[/(matr)ices$/i, '\1ix'], [/(quiz)zes$/i, '\1'], [/(database)s$/i, '\1']
|
|
39
|
+
].each { |rule, replacement| inflections.singular(rule, replacement) }
|
|
40
|
+
%w[person people man men child children sex sexes move moves zombie zombies].each_slice(2) { |one, many| inflections.irregular(one, many) }
|
|
41
|
+
inflections.uncountable(*%w[equipment information rice money species series fish sheep jeans police])
|
|
42
|
+
inflections
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def initialize
|
|
46
|
+
@plurals = []
|
|
47
|
+
@singulars = []
|
|
48
|
+
@uncountables = []
|
|
49
|
+
@acronyms = {}
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def pluralize(word) = apply(word, @plurals)
|
|
53
|
+
|
|
54
|
+
def singularize(word) = apply(word, @singulars)
|
|
55
|
+
|
|
56
|
+
# `api_keys` is `APIKeys` once `API` is an acronym.
|
|
57
|
+
def camelize(word)
|
|
58
|
+
word.split("_").map { |part| @acronyms[part] || part.capitalize }.join
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
# The other way: `APIKeysController` is `api_keys_controller`, as Active
|
|
62
|
+
# Support's `underscore` splits a run of capitals before its last one.
|
|
63
|
+
def underscore(camel)
|
|
64
|
+
camel.gsub(/([A-Z\d]+)([A-Z][a-z])/, '\1_\2').gsub(/([a-z\d])([A-Z])/, '\1_\2').downcase
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def plural(rule, replacement)
|
|
68
|
+
@uncountables.delete(rule) if rule.is_a?(String)
|
|
69
|
+
@uncountables.delete(replacement)
|
|
70
|
+
@plurals.unshift([rule, replacement])
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def singular(rule, replacement)
|
|
74
|
+
@uncountables.delete(rule) if rule.is_a?(String)
|
|
75
|
+
@uncountables.delete(replacement)
|
|
76
|
+
@singulars.unshift([rule, replacement])
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def irregular(one, many)
|
|
80
|
+
@uncountables.delete(one)
|
|
81
|
+
@uncountables.delete(many)
|
|
82
|
+
first, rest = one[0], one[1..]
|
|
83
|
+
many_first, many_rest = many[0], many[1..]
|
|
84
|
+
if first.upcase == many_first.upcase
|
|
85
|
+
plural(/(#{first})#{rest}$/i, "\\1#{many_rest}")
|
|
86
|
+
plural(/(#{many_first})#{many_rest}$/i, "\\1#{many_rest}")
|
|
87
|
+
singular(/(#{first})#{rest}$/i, "\\1#{rest}")
|
|
88
|
+
singular(/(#{many_first})#{many_rest}$/i, "\\1#{rest}")
|
|
89
|
+
else
|
|
90
|
+
plural(/#{first.upcase}(?i)#{rest}$/, many_first.upcase + many_rest)
|
|
91
|
+
plural(/#{first.downcase}(?i)#{rest}$/, many_first.downcase + many_rest)
|
|
92
|
+
plural(/#{many_first.upcase}(?i)#{many_rest}$/, many_first.upcase + many_rest)
|
|
93
|
+
plural(/#{many_first.downcase}(?i)#{many_rest}$/, many_first.downcase + many_rest)
|
|
94
|
+
singular(/#{first.upcase}(?i)#{rest}$/, first.upcase + rest)
|
|
95
|
+
singular(/#{first.downcase}(?i)#{rest}$/, first.downcase + rest)
|
|
96
|
+
singular(/#{many_first.upcase}(?i)#{many_rest}$/, first.upcase + rest)
|
|
97
|
+
singular(/#{many_first.downcase}(?i)#{many_rest}$/, first.downcase + rest)
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def uncountable(*words)
|
|
102
|
+
@uncountables.concat(words.flatten)
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def acronym(word)
|
|
106
|
+
@acronyms[word.downcase] = word
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
# Each call in the file whose arguments are all literals, in order.
|
|
110
|
+
def apply_calls(node)
|
|
111
|
+
node.compact_child_nodes.each { |child| apply_calls(child) }
|
|
112
|
+
return unless node.is_a?(Prism::CallNode) && READ.include?(node.name) && node.receiver
|
|
113
|
+
|
|
114
|
+
args = Array(node.arguments&.arguments).map { |arg| literal(arg) }
|
|
115
|
+
public_send(node.name, *args) unless args.empty? || args.include?(UNREAD)
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
READ = %i[plural singular irregular uncountable acronym].freeze
|
|
119
|
+
UNREAD = Object.new.freeze
|
|
120
|
+
|
|
121
|
+
private
|
|
122
|
+
|
|
123
|
+
def apply(word, rules)
|
|
124
|
+
return word if word.empty? || @uncountables.any? { |uncountable| word.match?(/\b#{Regexp.escape(uncountable)}\Z/i) }
|
|
125
|
+
|
|
126
|
+
result = word.dup
|
|
127
|
+
rules.each { |rule, replacement| break if result.sub!(rule, replacement) }
|
|
128
|
+
result
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def literal(node)
|
|
132
|
+
case node
|
|
133
|
+
when Prism::StringNode, Prism::SymbolNode then node.unescaped
|
|
134
|
+
when Prism::RegularExpressionNode then Regexp.new(node.unescaped, node.options)
|
|
135
|
+
when Prism::ArrayNode
|
|
136
|
+
items = node.elements.map { |element| literal(element) }
|
|
137
|
+
items.include?(UNREAD) ? UNREAD : items
|
|
138
|
+
else UNREAD
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,110 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module JitCatch
|
|
4
|
+
# Reads the report minitest_report.rb writes in a Minitest candidate's
|
|
5
|
+
# process, and Minitest's output for an error that stopped the file.
|
|
6
|
+
class MinitestParser
|
|
7
|
+
HEADER = /^(\S.*?)(?:\s+\[(.+):(\d+)\])?:\s*$/.freeze
|
|
8
|
+
STATUSES = { "F" => "fail", "E" => "error" }.freeze
|
|
9
|
+
# minitest_report.rb prints the error an error was raised from under
|
|
10
|
+
# this line, between the message and the frames.
|
|
11
|
+
CAUSED_BY = "--- Caused by: ---"
|
|
12
|
+
FINISHED = "finished"
|
|
13
|
+
|
|
14
|
+
def self.parse(report, stdout, stderr, normalizer: ->(text) { text })
|
|
15
|
+
new(report.to_s, normalizer.call(stdout.to_s), normalizer.call(stderr.to_s), normalizer).parse
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def initialize(report, stdout, stderr, normalizer)
|
|
19
|
+
@report = report
|
|
20
|
+
@stdout = stdout.gsub("\r\n", "\n")
|
|
21
|
+
@stderr = stderr.gsub("\r\n", "\n")
|
|
22
|
+
@normalizer = normalizer
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def parse
|
|
26
|
+
tests = {}
|
|
27
|
+
summary = false
|
|
28
|
+
@report.each_line do |raw|
|
|
29
|
+
# A process killed while it wrote leaves its last line cut.
|
|
30
|
+
next unless raw.end_with?("\n")
|
|
31
|
+
|
|
32
|
+
line = raw.chomp
|
|
33
|
+
next summary = true if line == FINISHED
|
|
34
|
+
|
|
35
|
+
name, code, text = line.split("\t", 3).map { |field| readable(field.undump) }
|
|
36
|
+
tests[name] = result(code, @normalizer.call(text.to_s))
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
file_error = summary ? nil : Failure.from_streams(@stdout, @stderr)
|
|
40
|
+
if !summary && tests.empty? && file_error.nil?
|
|
41
|
+
file_error = Failure.result("error", "RuntimeError", "minitest summary not found", [])
|
|
42
|
+
end
|
|
43
|
+
{ "tests" => tests, "lines" => {}, "summary" => summary, "file_error" => file_error }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
# A message can hold bytes that are not UTF-8; they read as "?", as in the
|
|
49
|
+
# streams the runner reads.
|
|
50
|
+
def readable(text)
|
|
51
|
+
text = text.dup.force_encoding(Encoding::UTF_8)
|
|
52
|
+
text.valid_encoding? ? text : text.scrub("?")
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
# The text is what Minitest prints for a result in its summary: the
|
|
56
|
+
# failure's label line, the test's location, then the message.
|
|
57
|
+
def result(code, text)
|
|
58
|
+
status = STATUSES[code]
|
|
59
|
+
return Failure.passed(code == "S" ? "skip" : "pass") unless status
|
|
60
|
+
|
|
61
|
+
parse_issue(status, text.gsub("\r\n", "\n").lines(chomp: true).drop(1))
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def parse_issue(status, block)
|
|
65
|
+
match = block.shift.to_s.match(HEADER)
|
|
66
|
+
frames = []
|
|
67
|
+
frames << Failure.frame(match[2], match[3]) if match&.[](2)
|
|
68
|
+
body, backtrace = status == "error" ? split_backtrace(block) : [block, []]
|
|
69
|
+
message_lines = []
|
|
70
|
+
quoting = false
|
|
71
|
+
body.each do |line|
|
|
72
|
+
# assert_raises prints the frames of the error it got between these
|
|
73
|
+
# two lines. The frames move to the backtrace, so the lines go too.
|
|
74
|
+
if line == "---Backtrace---" || (quoting && line.match?(/\A-+\z/))
|
|
75
|
+
quoting = !quoting
|
|
76
|
+
next
|
|
77
|
+
end
|
|
78
|
+
if quoting && (frame = line.match(Failure::FRAME))
|
|
79
|
+
frames << Failure.frame(frame[1], frame[2])
|
|
80
|
+
next
|
|
81
|
+
end
|
|
82
|
+
message_lines << line
|
|
83
|
+
end
|
|
84
|
+
error_class = "Minitest::Assertion" if status == "fail"
|
|
85
|
+
if status == "error" && (err = message_lines.first.to_s.match(/\A([A-Za-z0-9_:]+): ?(.*)\z/))
|
|
86
|
+
error_class = err[1]
|
|
87
|
+
message_lines[0] = err[2]
|
|
88
|
+
end
|
|
89
|
+
cause = nil
|
|
90
|
+
if status == "error" && (at = message_lines.index(CAUSED_BY))
|
|
91
|
+
cause_lines = message_lines.drop(at + 1)
|
|
92
|
+
message_lines = message_lines.take(at)
|
|
93
|
+
named = cause_lines.first.to_s.match(/\A([A-Za-z0-9_:]+): ?(.*)\z/)
|
|
94
|
+
cause = { "error_class" => named ? named[1] : "", "message" => [named ? named[2] : cause_lines.first.to_s, *cause_lines.drop(1)].join("\n") }
|
|
95
|
+
end
|
|
96
|
+
Failure.result(status, error_class, message_lines.join("\n"), frames + Failure.frames_in(backtrace), cause: cause)
|
|
97
|
+
end
|
|
98
|
+
|
|
99
|
+
# An error prints "Class: message", then each frame on a line of its own
|
|
100
|
+
# indented four spaces. A message line can look like a frame, so only the
|
|
101
|
+
# indented frames at the end of the block are the backtrace.
|
|
102
|
+
def split_backtrace(block)
|
|
103
|
+
lines = block.dup
|
|
104
|
+
lines.pop while lines.last&.strip&.empty?
|
|
105
|
+
at = lines.size
|
|
106
|
+
at -= 1 while at.positive? && lines[at - 1].start_with?(" ") && lines[at - 1].match?(Failure::FRAME)
|
|
107
|
+
[lines.take(at), lines.drop(at)]
|
|
108
|
+
end
|
|
109
|
+
end
|
|
110
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
# Loaded into a Minitest candidate's process with `ruby -r`, never into
|
|
4
|
+
# jitcatch itself. It writes each result to a report file for
|
|
5
|
+
# MinitestParser, since the lines Minitest prints for a test can be split or
|
|
6
|
+
# joined by what the test prints and by tests that run at once. It loads no
|
|
7
|
+
# library beyond minitest, so the candidate's process sees no other change.
|
|
8
|
+
begin
|
|
9
|
+
require "minitest"
|
|
10
|
+
rescue LoadError
|
|
11
|
+
# A bundle without minitest is named by the bundle check, not here.
|
|
12
|
+
else
|
|
13
|
+
# Minitest prints an error's class, message, and frames, but not the
|
|
14
|
+
# error it was raised from, so this puts that error's class and message
|
|
15
|
+
# between the message and the frames, under the line RSpec prints for
|
|
16
|
+
# the same thing.
|
|
17
|
+
Minitest::UnexpectedError.prepend(Module.new do
|
|
18
|
+
def message
|
|
19
|
+
text = super
|
|
20
|
+
cause = error.cause
|
|
21
|
+
head = "#{error.class}: #{error.message}"
|
|
22
|
+
return text unless cause && text.start_with?(head)
|
|
23
|
+
|
|
24
|
+
"#{head}\n--- Caused by: ---\n#{cause.class}: #{cause.message}#{text[head.length..]}"
|
|
25
|
+
end
|
|
26
|
+
end)
|
|
27
|
+
|
|
28
|
+
# One line per result: the test's name, its result code, and the text
|
|
29
|
+
# Minitest prints for it (a passing test's location), each as a
|
|
30
|
+
# String#dump literal and joined by tabs, then a last line once the run
|
|
31
|
+
# finished. Each line is written as it comes, so a run that is killed
|
|
32
|
+
# keeps the results it reported.
|
|
33
|
+
jitcatch_report = Class.new(Minitest::AbstractReporter) do
|
|
34
|
+
# Framework::REPORT, resolved before a test can change the directory.
|
|
35
|
+
define_method(:initialize) { @io = File.open(File.expand_path(".jitcatch-report.json"), "wb").tap { |io| io.sync = true } }
|
|
36
|
+
|
|
37
|
+
def record(result)
|
|
38
|
+
@io.write("#{[[result.class_name, result.name].join("#"), result.result_code, result.to_s].map(&:dump).join("\t")}\n")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def report
|
|
42
|
+
@io.write("finished\n")
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
Minitest.singleton_class.prepend(Module.new do
|
|
47
|
+
# Plugins add their reporters here. Registering an extension instead
|
|
48
|
+
# would stop Minitest 5 from loading the plugins of installed gems.
|
|
49
|
+
define_method(:init_plugins) do |options|
|
|
50
|
+
super(options)
|
|
51
|
+
reporter << jitcatch_report.new
|
|
52
|
+
end
|
|
53
|
+
end)
|
|
54
|
+
end
|