exhale 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/.ruby-version +1 -0
- data/CHANGELOG.md +12 -0
- data/LICENSE.txt +21 -0
- data/README.md +149 -0
- data/Rakefile +96 -0
- data/exe/exhale +6 -0
- data/exhale.gemspec +43 -0
- data/lib/exhale/cli.rb +148 -0
- data/lib/exhale/contract/markdown.rb +107 -0
- data/lib/exhale/contract/reference.rb +63 -0
- data/lib/exhale/contract/resolver.rb +142 -0
- data/lib/exhale/contract.rb +194 -0
- data/lib/exhale/dry/check.rb +266 -0
- data/lib/exhale/dry/fingerprints.rb +105 -0
- data/lib/exhale/dry/gate.rb +425 -0
- data/lib/exhale/dry/index.rb +59 -0
- data/lib/exhale/dry/matcher.rb +468 -0
- data/lib/exhale/dry/normalizer/erb.rb +289 -0
- data/lib/exhale/dry/normalizer/ruby.rb +193 -0
- data/lib/exhale/dry/normalizer.rb +26 -0
- data/lib/exhale/errors.rb +27 -0
- data/lib/exhale/git.rb +279 -0
- data/lib/exhale/report.rb +203 -0
- data/lib/exhale/shape.rb +32 -0
- data/lib/exhale/source_files.rb +84 -0
- data/lib/exhale/unit.rb +27 -0
- data/lib/exhale/units/erb.rb +30 -0
- data/lib/exhale/units/ruby.rb +283 -0
- data/lib/exhale/version.rb +5 -0
- data/lib/exhale.rb +25 -0
- metadata +152 -0
data/lib/exhale/git.rb
ADDED
|
@@ -0,0 +1,279 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "fileutils"
|
|
4
|
+
require "open3"
|
|
5
|
+
require "set"
|
|
6
|
+
require_relative "errors"
|
|
7
|
+
|
|
8
|
+
module Exhale
|
|
9
|
+
# Thin, deterministic wrapper over the git CLI. Every call uses argument
|
|
10
|
+
# arrays and ignores the user's quoting config.
|
|
11
|
+
class Git
|
|
12
|
+
# The user's git config must not change what exhale reads, or the same
|
|
13
|
+
# commit could get different verdicts on different machines. These pin
|
|
14
|
+
# every setting that changes diff or blame output.
|
|
15
|
+
BASE_ARGS = ["-c", "core.quotepath=off", "-c", "diff.renames=true", "-c", "diff.noprefix=false",
|
|
16
|
+
"-c", "diff.mnemonicPrefix=false", "-c", "diff.relative=false",
|
|
17
|
+
"-c", "diff.indentHeuristic=false", "-c", "diff.interHunkContext=0", "-c", "blame.ignoreRevsFile=",
|
|
18
|
+
"-c", "blame.markIgnoredLines=false", "-c", "blame.markUnblamableLines=false"].freeze
|
|
19
|
+
DEFAULT_CANDIDATES = %w[origin/main origin/master main master].freeze
|
|
20
|
+
# A blame header line: the commit, SHA-1 or SHA-256, then the original
|
|
21
|
+
# and final line numbers.
|
|
22
|
+
BLAME_HEADER = /\A(\h{40}(?:\h{24})?) \d+ (\d+)/
|
|
23
|
+
HUNK = /\A@@ -\d+(?:,\d+)? \+(\d+)(?:,(\d+))? @@/
|
|
24
|
+
|
|
25
|
+
attr_reader :root
|
|
26
|
+
|
|
27
|
+
def initialize(root)
|
|
28
|
+
@root = File.expand_path(root)
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def repo?
|
|
32
|
+
out, _err, status = run("rev-parse", "--is-inside-work-tree")
|
|
33
|
+
status.success? && out.strip == "true"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def head_sha
|
|
37
|
+
out, _err, status = run("rev-parse", "--verify", "--quiet", "HEAD^{commit}")
|
|
38
|
+
status.success? ? out.strip : nil
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def default_branch_ref
|
|
42
|
+
out, _err, status = run("symbolic-ref", "--quiet", "--short", "refs/remotes/origin/HEAD")
|
|
43
|
+
if status.success? && ref_exists?(out.strip)
|
|
44
|
+
return out.strip
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
DEFAULT_CANDIDATES.find { |ref| ref_exists?(ref) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def merge_base(ref)
|
|
51
|
+
return nil if ref.nil? || ref.empty?
|
|
52
|
+
|
|
53
|
+
out, _err, status = run("merge-base", ref, "HEAD")
|
|
54
|
+
status.success? && !out.strip.empty? ? out.strip : nil
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
# Where root sits inside the repository, like "agora/" for an app in a
|
|
58
|
+
# monorepo. Every path exhale reports is relative to root, so exports and
|
|
59
|
+
# diffs are scoped to it too.
|
|
60
|
+
def prefix
|
|
61
|
+
@prefix ||= run!("rev-parse", "--show-prefix").strip
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def toplevel
|
|
65
|
+
@toplevel ||= run!("rev-parse", "--show-toplevel").strip
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# A sparse checkout leaves tracked files out of the work tree, so a sweep of
|
|
69
|
+
# the head would read less than the commit.
|
|
70
|
+
def sparse?
|
|
71
|
+
out, _err, status = run("config", "--bool", "core.sparseCheckout")
|
|
72
|
+
return true if status.success? && out.strip == "true"
|
|
73
|
+
|
|
74
|
+
run!("ls-files", "-t", "-z").split("\0").any? { |entry| entry.start_with?("S ") }
|
|
75
|
+
end
|
|
76
|
+
|
|
77
|
+
def files
|
|
78
|
+
raise GitError, "exhale needs the full tree; this checkout is sparse" if sparse?
|
|
79
|
+
|
|
80
|
+
out = run!("ls-files", "--cached", "--others", "--exclude-standard", "-z")
|
|
81
|
+
out.split("\0").reject(&:empty?).uniq.select do |path|
|
|
82
|
+
full = File.join(root, path)
|
|
83
|
+
File.file?(full) && !File.symlink?(full)
|
|
84
|
+
end.sort
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Symlinks (mode 120000) and submodules (type commit) aren't source.
|
|
88
|
+
def files_at(sha)
|
|
89
|
+
blob_oids(sha).keys.sort
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
# Writes the named files at sha into dir as raw blobs. git archive would
|
|
93
|
+
# apply export-ignore and export-subst, and the base must be the commit
|
|
94
|
+
# exactly as it is.
|
|
95
|
+
def export_files(sha, paths, dir)
|
|
96
|
+
FileUtils.mkdir_p(dir)
|
|
97
|
+
return dir if paths.empty?
|
|
98
|
+
|
|
99
|
+
# Blobs are requested by object id: a path with a newline would split a
|
|
100
|
+
# "sha:path" request in two and shift every response after it.
|
|
101
|
+
oids = blob_oids(sha)
|
|
102
|
+
paths = paths.select { |path| oids.key?(path) }
|
|
103
|
+
return dir if paths.empty?
|
|
104
|
+
|
|
105
|
+
out, err, status = Open3.capture3("git", *BASE_ARGS, "-C", root, "cat-file", "--batch",
|
|
106
|
+
stdin_data: paths.map { |path| "#{oids[path]}\n" }.join, binmode: true)
|
|
107
|
+
raise GitError, "git cat-file failed: #{utf8(err).strip}" unless status.success?
|
|
108
|
+
|
|
109
|
+
write_blobs(out, paths, dir)
|
|
110
|
+
dir
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def changed_lines(base_sha)
|
|
114
|
+
diff = run!("diff", "--relative", "--unified=0", "--no-color", "--no-ext-diff", "--no-textconv",
|
|
115
|
+
"--find-renames", "--diff-algorithm=myers", "--src-prefix=a/", "--dst-prefix=b/",
|
|
116
|
+
base_sha, "--")
|
|
117
|
+
result = parse_diff(diff)
|
|
118
|
+
untracked_files.each { |path| result[path] = all_lines(path) }
|
|
119
|
+
result.reject { |_path, lines| lines.empty? }
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
# Committer time for every line of a file, index 0 being line 1, from one
|
|
123
|
+
# blame call. Uncommitted lines, and every line of an untracked file, are
|
|
124
|
+
# Float::INFINITY: newer than anything committed.
|
|
125
|
+
def blame_times(path)
|
|
126
|
+
full = File.join(root, path)
|
|
127
|
+
return Array.new(File.foreach(full).count, Float::INFINITY) unless tracked?(path)
|
|
128
|
+
|
|
129
|
+
# The flag, unlike an empty -c value, really does reset a repository's
|
|
130
|
+
# own blame.ignoreRevsFile.
|
|
131
|
+
out, err, status = run("blame", "--porcelain", "--no-ignore-revs-file", "--", path)
|
|
132
|
+
raise GitError, "git blame failed: #{err.strip}" unless status.success?
|
|
133
|
+
|
|
134
|
+
line_times(out)
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
def blame_time(path, start_line, end_line)
|
|
138
|
+
return Float::INFINITY unless tracked?(path)
|
|
139
|
+
|
|
140
|
+
out, err, status = run("blame", "--porcelain", "--no-ignore-revs-file", "-L", "#{start_line},#{end_line}",
|
|
141
|
+
"--", path)
|
|
142
|
+
raise GitError, "git blame failed: #{err.strip}" unless status.success?
|
|
143
|
+
|
|
144
|
+
newest_time(out)
|
|
145
|
+
end
|
|
146
|
+
|
|
147
|
+
private
|
|
148
|
+
|
|
149
|
+
# Output is UTF-8 whatever the locale, so no verdict depends on it.
|
|
150
|
+
def run(*args)
|
|
151
|
+
out, err, status = Open3.capture3("git", *BASE_ARGS, "-C", root, *args)
|
|
152
|
+
[utf8(out), utf8(err), status]
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def utf8(text)
|
|
156
|
+
text.dup.force_encoding(Encoding::UTF_8).scrub
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
# path => object id for each blob under root at sha. Symlinks (mode
|
|
160
|
+
# 120000) and submodules (type commit) aren't source.
|
|
161
|
+
def blob_oids(sha)
|
|
162
|
+
run!("ls-tree", "-r", "-z", sha).split("\0").each_with_object({}) do |entry, oids|
|
|
163
|
+
meta, path = entry.split("\t", 2)
|
|
164
|
+
mode, type, oid = meta.split(" ")
|
|
165
|
+
oids[path] = oid if type == "blob" && mode != "120000"
|
|
166
|
+
end
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def run!(*args)
|
|
170
|
+
out, err, status = run(*args)
|
|
171
|
+
raise GitError, "git #{args.first} failed: #{err.strip}" unless status.success?
|
|
172
|
+
|
|
173
|
+
out
|
|
174
|
+
end
|
|
175
|
+
|
|
176
|
+
def ref_exists?(ref)
|
|
177
|
+
_out, _err, status = run("rev-parse", "--verify", "--quiet", "#{ref}^{commit}")
|
|
178
|
+
status.success?
|
|
179
|
+
end
|
|
180
|
+
|
|
181
|
+
def tracked?(path)
|
|
182
|
+
_out, _err, status = run("ls-files", "--error-unmatch", "--", path)
|
|
183
|
+
status.success?
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
def untracked_files
|
|
187
|
+
run!("ls-files", "--others", "--exclude-standard", "-z").split("\0").reject(&:empty?).sort
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def all_lines(path)
|
|
191
|
+
full = File.join(root, path)
|
|
192
|
+
return Set.new unless File.file?(full)
|
|
193
|
+
|
|
194
|
+
count = File.foreach(full).count
|
|
195
|
+
Set.new(1..count)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# File headers only count between a "diff --git" line and the first hunk,
|
|
199
|
+
# so an added line that happens to start with "++ " can't pass for one.
|
|
200
|
+
def parse_diff(diff)
|
|
201
|
+
result = Hash.new { |h, k| h[k] = Set.new }
|
|
202
|
+
current = nil
|
|
203
|
+
in_header = false
|
|
204
|
+
diff.each_line(chomp: true) do |line|
|
|
205
|
+
if line.start_with?("diff --git ")
|
|
206
|
+
in_header = true
|
|
207
|
+
current = nil
|
|
208
|
+
elsif in_header && line.start_with?("+++ ")
|
|
209
|
+
current = new_path(line)
|
|
210
|
+
elsif (m = HUNK.match(line))
|
|
211
|
+
in_header = false
|
|
212
|
+
add_hunk(result[current], m[1].to_i, (m[2] || "1").to_i) if current
|
|
213
|
+
end
|
|
214
|
+
end
|
|
215
|
+
result.to_h
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
# git appends a tab to a header path that contains a space, and quotes a
|
|
219
|
+
# path with characters it won't print raw.
|
|
220
|
+
def new_path(line)
|
|
221
|
+
target = line.delete_prefix("+++ ").delete_suffix("\t")
|
|
222
|
+
return nil if target == "/dev/null"
|
|
223
|
+
|
|
224
|
+
target = target.undump if target.start_with?('"')
|
|
225
|
+
target.delete_prefix("b/")
|
|
226
|
+
end
|
|
227
|
+
|
|
228
|
+
def write_blobs(out, paths, dir)
|
|
229
|
+
pos = 0
|
|
230
|
+
paths.each do |path|
|
|
231
|
+
header_end = out.index("\n", pos)
|
|
232
|
+
header = out[pos...header_end]
|
|
233
|
+
pos = header_end + 1
|
|
234
|
+
next if header.end_with?(" missing")
|
|
235
|
+
|
|
236
|
+
size = header.split(" ").last.to_i
|
|
237
|
+
full = File.join(dir, path)
|
|
238
|
+
FileUtils.mkdir_p(File.dirname(full))
|
|
239
|
+
File.binwrite(full, out.byteslice(pos, size))
|
|
240
|
+
pos += size + 1
|
|
241
|
+
end
|
|
242
|
+
end
|
|
243
|
+
|
|
244
|
+
def add_hunk(set, start, count)
|
|
245
|
+
count.times { |i| set << start + i }
|
|
246
|
+
end
|
|
247
|
+
|
|
248
|
+
# Porcelain names a commit's metadata only the first time the commit
|
|
249
|
+
# appears, so times are remembered per SHA and handed to each line.
|
|
250
|
+
def line_times(porcelain)
|
|
251
|
+
times = {}
|
|
252
|
+
result = []
|
|
253
|
+
sha = nil
|
|
254
|
+
final = nil
|
|
255
|
+
porcelain.each_line(chomp: true) do |line|
|
|
256
|
+
if line.start_with?("\t")
|
|
257
|
+
result[final - 1] = uncommitted?(sha) ? Float::INFINITY : times.fetch(sha, 0)
|
|
258
|
+
elsif (m = BLAME_HEADER.match(line))
|
|
259
|
+
sha = m[1]
|
|
260
|
+
final = m[2].to_i
|
|
261
|
+
elsif line.start_with?("committer-time ")
|
|
262
|
+
times[sha] = line.split(" ", 2).last.to_i
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
result
|
|
266
|
+
end
|
|
267
|
+
|
|
268
|
+
def uncommitted?(sha)
|
|
269
|
+
sha.match?(/\A0+\z/)
|
|
270
|
+
end
|
|
271
|
+
|
|
272
|
+
def newest_time(porcelain)
|
|
273
|
+
return Float::INFINITY if porcelain.each_line.any? { |l| (m = BLAME_HEADER.match(l)) && uncommitted?(m[1]) }
|
|
274
|
+
|
|
275
|
+
times = porcelain.each_line.filter_map { |l| l.split(" ", 2).last.to_i if l.start_with?("committer-time ") }
|
|
276
|
+
times.max or raise GitError, "no blame data"
|
|
277
|
+
end
|
|
278
|
+
end
|
|
279
|
+
end
|
|
@@ -0,0 +1,203 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
require_relative "version"
|
|
5
|
+
require_relative "dry/normalizer"
|
|
6
|
+
|
|
7
|
+
module Exhale
|
|
8
|
+
# Renders a duplication check's result as text for the agent doing the
|
|
9
|
+
# exhale, JSON for tools, or EDN in the shape dryer writes to
|
|
10
|
+
# .metrics/dry.edn.
|
|
11
|
+
module Report
|
|
12
|
+
FORMATS = %w[text json edn].freeze
|
|
13
|
+
LABELS = { introduced: "INTRODUCED", shifted: "SHIFTED", already_there: "ALREADY THERE", found: "FOUND" }.freeze
|
|
14
|
+
|
|
15
|
+
module_function
|
|
16
|
+
|
|
17
|
+
def render(result, format)
|
|
18
|
+
case format
|
|
19
|
+
when "text" then Text.new(result).render
|
|
20
|
+
when "json" then JSON.pretty_generate(Json.new(result).to_h)
|
|
21
|
+
when "edn" then Edn.new(result).render
|
|
22
|
+
else raise ArgumentError, "unknown format #{format.inspect}"
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def counts(result)
|
|
27
|
+
counts = Hash.new(0)
|
|
28
|
+
result.findings.each { |f| counts[f.klass] += 1 }
|
|
29
|
+
counts[:kept] = result.kept.size
|
|
30
|
+
counts[:contracted] = result.contracted.size
|
|
31
|
+
counts
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def kind_name(kind, location)
|
|
35
|
+
kind == :fragment ? "fragment, #{location.lines} lines" : kind.to_s
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def score(value)
|
|
39
|
+
value.to_f.round(2)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
class Text
|
|
43
|
+
def initialize(result)
|
|
44
|
+
@result = result
|
|
45
|
+
@out = []
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def render
|
|
49
|
+
@out << header
|
|
50
|
+
@result.notes.each { |note| @out << "note: #{note}" }
|
|
51
|
+
@result.findings.each { |f| finding(f) }
|
|
52
|
+
@result.kept.each { |k| kept(k) }
|
|
53
|
+
contracted
|
|
54
|
+
errors
|
|
55
|
+
"#{@out.join("\n")}\n"
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def header
|
|
61
|
+
counts = Report.counts(@result)
|
|
62
|
+
parts = %i[introduced shifted already_there found kept contracted].filter_map do |key|
|
|
63
|
+
"#{counts[key]} #{key.to_s.tr('_', ' ')}" if counts[key].positive?
|
|
64
|
+
end
|
|
65
|
+
parts << plural(@result.clause_errors.size, "contract error") unless @result.clause_errors.empty?
|
|
66
|
+
parts << plural(@result.parse_errors.size, "parse error") unless @result.parse_errors.empty?
|
|
67
|
+
parts << (@result.exit_code.zero? ? "clean" : "failing") if parts.empty?
|
|
68
|
+
base = @result.base_sha ? " base #{@result.base_sha[0, 7]}" : ""
|
|
69
|
+
"exhale dry: #{parts.join(', ')}#{base}"
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def plural(count, noun)
|
|
73
|
+
"#{count} #{noun}#{'s' unless count == 1}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def finding(f)
|
|
77
|
+
@out << ""
|
|
78
|
+
@out << "#{LABELS.fetch(f.klass)} #{Report.score(f.score)} #{Report.kind_name(f.kind, f.copy)}"
|
|
79
|
+
rows = [[f.touched.include?(f.copy) ? "new" : "copy", f.copy, nil]]
|
|
80
|
+
f.others.each { |l, s| rows << ["existing", l, f.others.size > 1 ? Report.score(s) : nil] }
|
|
81
|
+
table(rows)
|
|
82
|
+
@out << " hint #{f.hint}"
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def kept(k)
|
|
86
|
+
@out << ""
|
|
87
|
+
flag = k.new_clause ? " NEW CLAUSE, review its reason" : ""
|
|
88
|
+
@out << "KEPT #{Report.score(k.score)} #{k.clause.path}:#{k.clause.line} \"#{k.clause.heading}\"#{flag}"
|
|
89
|
+
table([["side", k.a, nil], ["side", k.b, nil]])
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def table(rows)
|
|
93
|
+
width = rows.map { |_, l, _| place(l).size }.max
|
|
94
|
+
ident = rows.map { |_, l, _| l.unit.identity.size }.max
|
|
95
|
+
rows.each do |label, l, s|
|
|
96
|
+
line = " #{label.ljust(9)} #{place(l).ljust(width)} #{l.unit.identity.ljust(ident)}"
|
|
97
|
+
line += " #{s}" if s
|
|
98
|
+
@out << line.rstrip
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def place(location)
|
|
103
|
+
"#{location.path}:#{location.start_line}-#{location.end_line}"
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def contracted
|
|
107
|
+
return if @result.contracted.empty?
|
|
108
|
+
|
|
109
|
+
@out << ""
|
|
110
|
+
@out << "CONTRACTED"
|
|
111
|
+
@result.contracted.first(20).each do |c|
|
|
112
|
+
@out << " #{c.a} no longer matches #{c.b} (#{Report.score(c.score)} at base)"
|
|
113
|
+
end
|
|
114
|
+
more = @result.contracted.size - 20
|
|
115
|
+
@out << " and #{more} more" if more.positive?
|
|
116
|
+
end
|
|
117
|
+
|
|
118
|
+
def errors
|
|
119
|
+
return if @result.clause_errors.empty? && @result.parse_errors.empty?
|
|
120
|
+
|
|
121
|
+
@out << ""
|
|
122
|
+
@result.clause_errors.each { |e| @out << "CONTRACT #{e.message}" }
|
|
123
|
+
@result.parse_errors.each { |e| @out << "PARSE #{e.message}" }
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
|
|
127
|
+
class Json
|
|
128
|
+
def initialize(result)
|
|
129
|
+
@result = result
|
|
130
|
+
end
|
|
131
|
+
|
|
132
|
+
def to_h
|
|
133
|
+
{
|
|
134
|
+
"exhale" => VERSION,
|
|
135
|
+
"check" => "dry",
|
|
136
|
+
"normalizer" => Dry::Normalizer::VERSION,
|
|
137
|
+
"base" => @result.base_sha,
|
|
138
|
+
"exit_code" => @result.exit_code,
|
|
139
|
+
"counts" => Report.counts(@result).transform_keys(&:to_s),
|
|
140
|
+
"notes" => @result.notes,
|
|
141
|
+
"findings" => @result.findings.map { |f| finding(f) },
|
|
142
|
+
"kept" => @result.kept.map { |k| kept(k) },
|
|
143
|
+
"contracted" => @result.contracted.map { |c| { "a" => c.a, "b" => c.b, "score" => Report.score(c.score) } },
|
|
144
|
+
"contract_errors" => @result.clause_errors.map { |e| error(e) },
|
|
145
|
+
"parse_errors" => @result.parse_errors.map { |e| error(e) }
|
|
146
|
+
}
|
|
147
|
+
end
|
|
148
|
+
|
|
149
|
+
private
|
|
150
|
+
|
|
151
|
+
def finding(f)
|
|
152
|
+
{
|
|
153
|
+
"class" => f.klass.to_s, "score" => Report.score(f.score), "kind" => f.kind.to_s,
|
|
154
|
+
"copy" => location(f.copy, f.touched),
|
|
155
|
+
"existing" => f.others.map { |l, s| location(l, f.touched).merge("score" => Report.score(s)) },
|
|
156
|
+
"hint" => f.hint, "payoff" => f.payoff, "primitive" => f.primitive
|
|
157
|
+
}
|
|
158
|
+
end
|
|
159
|
+
|
|
160
|
+
def kept(k)
|
|
161
|
+
{
|
|
162
|
+
"score" => Report.score(k.score), "a" => location(k.a, []), "b" => location(k.b, []),
|
|
163
|
+
"clause" => { "path" => k.clause.path, "line" => k.clause.line, "heading" => k.clause.heading },
|
|
164
|
+
"new_clause" => k.new_clause
|
|
165
|
+
}
|
|
166
|
+
end
|
|
167
|
+
|
|
168
|
+
def location(l, touched)
|
|
169
|
+
{ "path" => l.path, "start_line" => l.start_line, "end_line" => l.end_line, "identity" => l.unit.identity,
|
|
170
|
+
"nodes" => l.size, "touched" => touched.include?(l) }
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
def error(e)
|
|
174
|
+
{ "path" => e.path, "line" => e.line, "message" => e.message }
|
|
175
|
+
end
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
# The candidates list dryer writes, one entry per copy and existing pair.
|
|
179
|
+
class Edn
|
|
180
|
+
def initialize(result)
|
|
181
|
+
@result = result
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
def render
|
|
185
|
+
rows = @result.findings.flat_map do |f|
|
|
186
|
+
f.others.map { |l, s| candidate(f.copy, l, s) }
|
|
187
|
+
end
|
|
188
|
+
"{:candidates\n [#{rows.join("\n ")}]}\n"
|
|
189
|
+
end
|
|
190
|
+
|
|
191
|
+
private
|
|
192
|
+
|
|
193
|
+
def candidate(left, right, score)
|
|
194
|
+
"{:score #{score.to_f.round(12)}\n :language #{left.unit.language.to_s.inspect}\n " \
|
|
195
|
+
":left #{side(left)}\n :right #{side(right)}\n :left-nodes #{left.size}\n :right-nodes #{right.size}}"
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
def side(l)
|
|
199
|
+
"{:file #{l.path.inspect}, :start-line #{l.start_line}, :end-line #{l.end_line}}"
|
|
200
|
+
end
|
|
201
|
+
end
|
|
202
|
+
end
|
|
203
|
+
end
|
data/lib/exhale/shape.rb
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exhale
|
|
4
|
+
# One node of a normalized tree. Normalization keeps the names of
|
|
5
|
+
# operations and the shape of the tree, and replaces the names of things
|
|
6
|
+
# with markers, so two units that do the same thing to different locals
|
|
7
|
+
# produce equal trees.
|
|
8
|
+
#
|
|
9
|
+
# kind - String. A Prism or Herb node type after normalization
|
|
10
|
+
# ("call_node", "if_node", "html_element_node"), or a marker
|
|
11
|
+
# for a dropped name: ":local", ":ivar", ":cvar", ":gvar",
|
|
12
|
+
# ":const", ":literal".
|
|
13
|
+
# label - String or nil. A name normalization keeps: the method name at
|
|
14
|
+
# a call site (operators are calls too), an HTML tag name, an
|
|
15
|
+
# attribute name, or a kept attribute value (data-controller,
|
|
16
|
+
# data-action).
|
|
17
|
+
# children - Array of Shape, in source order.
|
|
18
|
+
# start_line - 1-based first line in the unit's file.
|
|
19
|
+
# end_line - 1-based last line.
|
|
20
|
+
# sequence - true when children are a run of statements or sibling
|
|
21
|
+
# markup (a method body, a block body, an element's children).
|
|
22
|
+
# Statement-run fragments come only from sequences.
|
|
23
|
+
Shape = Struct.new(:kind, :label, :children, :start_line, :end_line, :sequence, keyword_init: true) do
|
|
24
|
+
def leaf?
|
|
25
|
+
children.empty?
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def lines
|
|
29
|
+
end_line - start_line + 1
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
|
|
5
|
+
module Exhale
|
|
6
|
+
# Lists the Ruby and ERB files exhale analyses.
|
|
7
|
+
module SourceFiles
|
|
8
|
+
EXCLUDED_ROOTS = %w[vendor node_modules tmp log coverage public storage bin].freeze
|
|
9
|
+
EXCLUDED_PREFIXES = ["app/assets/builds/"].freeze
|
|
10
|
+
# Schema, migrations and settings, at the root and in each engine or pack
|
|
11
|
+
# of a monorepo.
|
|
12
|
+
EXCLUDED_DIRS = %r{\A(?:(?:engines|packs|components|gems)/[^/]+/)?(?:db|config)/}
|
|
13
|
+
# A generator's header: a comment line that opens with the marker. A
|
|
14
|
+
# comment that only mentions generated code somewhere doesn't count.
|
|
15
|
+
GENERATED = %r{
|
|
16
|
+
\A\s*(?:<%\#|\#|//)\s*
|
|
17
|
+
(?:this\ file\ is\ auto-?generated|this\ file\ is\ automatically\ generated|auto-?generated\ by
|
|
18
|
+
|generated\ by\b.*\bdo\ not\ edit|do\ not\ edit|code\ generated\b.*\bdo\ not\ edit)
|
|
19
|
+
}xi
|
|
20
|
+
# Enough of a file to hold its first five lines.
|
|
21
|
+
HEADER_BYTES = 64 * 1024
|
|
22
|
+
|
|
23
|
+
module_function
|
|
24
|
+
|
|
25
|
+
def list(root, files: nil, include_tests: false)
|
|
26
|
+
candidates = files ? files.map(&:to_s) : walk(root)
|
|
27
|
+
candidates
|
|
28
|
+
.select { |path| language(path) && path_allowed?(path, include_tests) }
|
|
29
|
+
.sort
|
|
30
|
+
.uniq
|
|
31
|
+
.reject { |path| linked?(root, path) }
|
|
32
|
+
.select { |path| File.exist?(File.join(root, path)) }
|
|
33
|
+
.reject { |path| generated?(File.join(root, path)) }
|
|
34
|
+
.map { |path| [path, language(path)] }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def language(path)
|
|
38
|
+
case path
|
|
39
|
+
when /\.rb\z/ then :ruby
|
|
40
|
+
# Herb reads HTML with ERB in it. Text, JSON and prompt templates that
|
|
41
|
+
# happen to use ERB aren't HTML, so they aren't compared.
|
|
42
|
+
when /\.html(\+[\w-]+)?\.erb\z/, /\.turbo_stream\.erb\z/ then :erb
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def walk(root)
|
|
47
|
+
Dir.glob("**/*", base: root).select do |path|
|
|
48
|
+
full = File.join(root, path)
|
|
49
|
+
File.file?(full) && !File.symlink?(full)
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# A symlink, or a path under a symlinked directory, points outside the
|
|
54
|
+
# tree exhale was asked to read, or at a file it already reads.
|
|
55
|
+
def linked?(root, path)
|
|
56
|
+
segments = path.split("/")
|
|
57
|
+
segments.each_index.any? { |i| File.symlink?(File.join(root, *segments[0..i])) }
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def path_allowed?(path, include_tests)
|
|
61
|
+
segments = path.split("/")
|
|
62
|
+
return false if segments.any? { |s| s.start_with?(".") }
|
|
63
|
+
return false if EXCLUDED_ROOTS.include?(segments.first) || path.match?(EXCLUDED_DIRS)
|
|
64
|
+
return false if EXCLUDED_PREFIXES.any? { |p| path.start_with?(p) }
|
|
65
|
+
|
|
66
|
+
include_tests || !test_path?(path, segments)
|
|
67
|
+
end
|
|
68
|
+
|
|
69
|
+
def test_path?(path, segments)
|
|
70
|
+
segments[0..-2].any? { |s| %w[spec test].include?(s) } || path.end_with?("_spec.rb", "_test.rb")
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
# Only a generator's header takes a file off the list. A file exhale
|
|
74
|
+
# can't read, or that isn't valid UTF-8, stays on it so the sweep reports
|
|
75
|
+
# it as a parse error; dropping it here would let it through the gate
|
|
76
|
+
# unread.
|
|
77
|
+
def generated?(full)
|
|
78
|
+
head = File.open(full, "rb") { |file| file.read(HEADER_BYTES) }.to_s
|
|
79
|
+
head.force_encoding(Encoding::UTF_8).scrub.each_line.first(5).any? { |line| line.match?(GENERATED) }
|
|
80
|
+
rescue SystemCallError
|
|
81
|
+
false
|
|
82
|
+
end
|
|
83
|
+
end
|
|
84
|
+
end
|
data/lib/exhale/unit.rb
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Exhale
|
|
4
|
+
# Anything that can appear in a finding: a method, the body of a Rails DSL
|
|
5
|
+
# call, or an ERB template. Fragments are found inside units later; they
|
|
6
|
+
# aren't units of their own.
|
|
7
|
+
#
|
|
8
|
+
# kind - :method, :dsl or :template.
|
|
9
|
+
# identity - Survives a file move. "Billing::Invoice#total" for an instance
|
|
10
|
+
# method, "Billing::Invoice.build" for a singleton method,
|
|
11
|
+
# "Order.scope(:settled)" or "Order.before_save[2]" for a DSL
|
|
12
|
+
# body, "views/orders/_form.html.erb" for a template (its path
|
|
13
|
+
# under app/).
|
|
14
|
+
# namespace - "Billing::Invoice" for a Ruby unit ("Object" at top level),
|
|
15
|
+
# nil for a template.
|
|
16
|
+
# name - "total", "build", "scope(:settled)"; nil for a template.
|
|
17
|
+
# path - Path relative to the repository root.
|
|
18
|
+
# language - :ruby or :erb.
|
|
19
|
+
# node - The parser node the unit covers (a Prism::Node, or a Herb
|
|
20
|
+
# node). Held in memory only; never cached.
|
|
21
|
+
Unit = Struct.new(:kind, :identity, :namespace, :name, :path, :start_line, :end_line, :language, :node,
|
|
22
|
+
keyword_init: true) do
|
|
23
|
+
def lines
|
|
24
|
+
end_line - start_line + 1
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "herb"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "../unit"
|
|
6
|
+
|
|
7
|
+
module Exhale
|
|
8
|
+
module Units
|
|
9
|
+
# A template is one unit. Duplication inside it is found later, as
|
|
10
|
+
# fragments of its tree.
|
|
11
|
+
module Erb
|
|
12
|
+
module_function
|
|
13
|
+
|
|
14
|
+
def extract(source, path)
|
|
15
|
+
# strict: false lets valid HTML through (an omitted `</li>`); broken
|
|
16
|
+
# markup and broken Ruby are still errors.
|
|
17
|
+
result = Herb.parse(source, strict: false)
|
|
18
|
+
raise_first_error(result.errors, path) unless result.errors.empty?
|
|
19
|
+
|
|
20
|
+
[Unit.new(kind: :template, identity: path.delete_prefix("app/"), namespace: nil, name: nil, path: path,
|
|
21
|
+
start_line: 1, end_line: [source.lines.size, 1].max, language: :erb, node: result.value)]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def raise_first_error(errors, path)
|
|
25
|
+
error = errors.min_by { |e| [e.location.start.line, e.location.start.column] }
|
|
26
|
+
raise ParseError.new(path, error.location.start.line, error.message)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|