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.
@@ -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