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
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "../unit"
|
|
4
|
+
require_relative "../errors"
|
|
5
|
+
require_relative "reference"
|
|
6
|
+
|
|
7
|
+
module Exhale
|
|
8
|
+
class Contract
|
|
9
|
+
# Maps Contract references onto code units. Units are indexed once (by
|
|
10
|
+
# identity and by every namespace prefix) so each reference costs a hash
|
|
11
|
+
# lookup instead of a scan.
|
|
12
|
+
class Resolver
|
|
13
|
+
def initialize(contract, units)
|
|
14
|
+
@contract = contract
|
|
15
|
+
@units = units
|
|
16
|
+
@order = {}.compare_by_identity
|
|
17
|
+
units.each_with_index { |u, i| @order[u] = i }
|
|
18
|
+
@matches = {}
|
|
19
|
+
build_indexes
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def errors
|
|
23
|
+
@errors ||= all_references.sort_by { |r| [r.path, r.line] }.filter_map do |ref|
|
|
24
|
+
next unless units_for(ref).empty?
|
|
25
|
+
|
|
26
|
+
ContractError.new(ref.path, ref.line, "reference names no unit: #{ref.text}")
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def units_for(reference)
|
|
31
|
+
@units_for ||= {}
|
|
32
|
+
@units_for[reference.text] ||= matches(reference).keys.sort_by { |u| @order[u] }
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def keeping_clause(unit_a, unit_b)
|
|
36
|
+
return nil if unit_a.equal?(unit_b)
|
|
37
|
+
|
|
38
|
+
@contract.clauses.find { |clause| keeps?(clause, unit_a, unit_b) }
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def primitive_for(unit)
|
|
42
|
+
pairs = covering(unit)
|
|
43
|
+
top = pairs.map { |_, ref| ref.specificity }.max
|
|
44
|
+
pairs.select { |_, ref| ref.specificity == top }.map(&:first).min_by(&:name)
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def settings_for(unit, defaults)
|
|
48
|
+
prim = primitive_for(unit)
|
|
49
|
+
defaults.merge(prim ? @contract.settings.fetch(prim.name, {}) : {})
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def settings_for_pair(unit_a, unit_b, defaults)
|
|
53
|
+
a = settings_for(unit_a, defaults)
|
|
54
|
+
b = settings_for(unit_b, defaults)
|
|
55
|
+
a.merge(b) { |_key, x, y| [x, y].min }
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
private
|
|
59
|
+
|
|
60
|
+
def build_indexes
|
|
61
|
+
@by_identity = Hash.new { |h, k| h[k] = [] }
|
|
62
|
+
@by_prefix = Hash.new { |h, k| h[k] = [] }
|
|
63
|
+
@templates = []
|
|
64
|
+
@units.each { |unit| index(unit) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def index(unit)
|
|
68
|
+
@templates << unit if unit.kind == :template
|
|
69
|
+
@by_identity[unit.identity] << unit if %i[method dsl].include?(unit.kind)
|
|
70
|
+
return unless unit.namespace
|
|
71
|
+
|
|
72
|
+
segments = unit.namespace.split("::")
|
|
73
|
+
(1..segments.size).each { |n| @by_prefix[segments.first(n).join("::")] << unit }
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def all_references
|
|
77
|
+
@contract.primitives.flat_map(&:covers) + @contract.clauses.flat_map(&:references)
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
# {unit => match key}, cached per reference text.
|
|
81
|
+
def matches(ref)
|
|
82
|
+
@matches[ref.text] ||= case ref.kind
|
|
83
|
+
when :method then method_matches(ref)
|
|
84
|
+
when :constant then constant_matches(ref)
|
|
85
|
+
when :const_glob then const_glob_matches(ref)
|
|
86
|
+
else template_matches(ref)
|
|
87
|
+
end
|
|
88
|
+
end
|
|
89
|
+
|
|
90
|
+
def method_matches(ref)
|
|
91
|
+
@by_identity.fetch(ref.text, []).to_h { |u| [u, ref.text] }.compare_by_identity
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def constant_matches(ref)
|
|
95
|
+
@by_prefix.fetch(ref.text, []).to_h { |u| [u, ref.text] }.compare_by_identity
|
|
96
|
+
end
|
|
97
|
+
|
|
98
|
+
# A unit under several matching prefixes keys on the longest one.
|
|
99
|
+
def const_glob_matches(ref)
|
|
100
|
+
result = {}.compare_by_identity
|
|
101
|
+
@by_prefix.each do |prefix, units|
|
|
102
|
+
next unless ref.const_glob_match?(prefix.split("::"))
|
|
103
|
+
|
|
104
|
+
units.each { |u| result[u] = prefix if !result.key?(u) || result[u].length < prefix.length }
|
|
105
|
+
end
|
|
106
|
+
result
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def template_matches(ref)
|
|
110
|
+
regex = ref.template_regex
|
|
111
|
+
@templates.select { |u| regex.match?(u.identity) }.to_h { |u| [u, u.identity] }.compare_by_identity
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def keeps?(clause, unit_a, unit_b)
|
|
115
|
+
a = assignment(clause, unit_a)
|
|
116
|
+
b = assignment(clause, unit_b)
|
|
117
|
+
a && b && a != b
|
|
118
|
+
end
|
|
119
|
+
|
|
120
|
+
# The one reference (and match key) a unit belongs to within a clause:
|
|
121
|
+
# the most specific of those that match it.
|
|
122
|
+
def assignment(clause, unit)
|
|
123
|
+
hits = clause.references.uniq(&:text).filter_map { |r| (k = matches(r)[unit]) && [r, k] }
|
|
124
|
+
ref, key = hits.max_by { |r, _| r.specificity }
|
|
125
|
+
ref && [ref.text, key]
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
def covering(unit)
|
|
129
|
+
@covered_by ||= build_covered_by
|
|
130
|
+
@covered_by.fetch(unit, [])
|
|
131
|
+
end
|
|
132
|
+
|
|
133
|
+
def build_covered_by
|
|
134
|
+
map = {}.compare_by_identity
|
|
135
|
+
@contract.primitives.each do |prim|
|
|
136
|
+
prim.covers.each { |ref| matches(ref).each_key { |u| (map[u] ||= []) << [prim, ref] } }
|
|
137
|
+
end
|
|
138
|
+
map
|
|
139
|
+
end
|
|
140
|
+
end
|
|
141
|
+
end
|
|
142
|
+
end
|
|
@@ -0,0 +1,194 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "find"
|
|
5
|
+
require "pathname"
|
|
6
|
+
require_relative "unit"
|
|
7
|
+
require_relative "errors"
|
|
8
|
+
require_relative "contract/markdown"
|
|
9
|
+
require_relative "contract/reference"
|
|
10
|
+
require_relative "contract/resolver"
|
|
11
|
+
|
|
12
|
+
module Exhale
|
|
13
|
+
# The Contract: Markdown under <root>/contract/<primitive>/ that declares which
|
|
14
|
+
# code belongs to a primitive and which units are deliberately parallel.
|
|
15
|
+
class Contract
|
|
16
|
+
Primitive = Struct.new(:name, :path, :covers)
|
|
17
|
+
Clause = Struct.new(:primitive, :kind, :path, :line, :heading, :reason, :references, :key)
|
|
18
|
+
|
|
19
|
+
CONTENT = %w[covers parallel settings].freeze
|
|
20
|
+
SETTING_KEYS = { "threshold" => :threshold, "min-lines" => :min_lines, "min-nodes" => :min_nodes }.freeze
|
|
21
|
+
|
|
22
|
+
attr_reader :primitives, :clauses, :settings, :errors
|
|
23
|
+
|
|
24
|
+
def self.load(root, dir: "contract")
|
|
25
|
+
new(root, dir).tap(&:parse)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def initialize(root, dir)
|
|
29
|
+
@root = Pathname.new(root.to_s)
|
|
30
|
+
@dir = dir
|
|
31
|
+
@primitives = []
|
|
32
|
+
@clauses = []
|
|
33
|
+
@settings = {}
|
|
34
|
+
@errors = []
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def parse
|
|
38
|
+
base = @root.join(@dir)
|
|
39
|
+
return self unless base.directory? || base.symlink?
|
|
40
|
+
|
|
41
|
+
flag_symlinks(base)
|
|
42
|
+
return self if base.symlink?
|
|
43
|
+
|
|
44
|
+
check_root_files(base)
|
|
45
|
+
base.children.select { |d| real_dir?(d) }.reject { |d| d.basename.to_s.start_with?(".") }
|
|
46
|
+
.sort_by { |d| d.basename.to_s }.each { |d| parse_primitive(d) }
|
|
47
|
+
@clauses.sort_by! { |c| [c.path, c.line] }
|
|
48
|
+
self
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
# Symlinks could point outside the repo and change the verdict without
|
|
54
|
+
# changing the commit, so every one is an error and none is followed.
|
|
55
|
+
def flag_symlinks(base)
|
|
56
|
+
Find.find(base.to_s) do |path|
|
|
57
|
+
next unless File.symlink?(path)
|
|
58
|
+
|
|
59
|
+
@errors << ContractError.new(rel(path), 1, "symlinks are not allowed in the Contract")
|
|
60
|
+
Find.prune
|
|
61
|
+
end
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
def real_dir?(path)
|
|
65
|
+
path.directory? && !path.symlink?
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def real_file?(path)
|
|
69
|
+
path.file? && !path.symlink?
|
|
70
|
+
end
|
|
71
|
+
|
|
72
|
+
def parse_primitive(dir)
|
|
73
|
+
name = dir.basename.to_s
|
|
74
|
+
covers = []
|
|
75
|
+
readme = dir.join("README.md")
|
|
76
|
+
each_block(readme) { |block, path| readme_block(covers, block, path) } if real_file?(readme)
|
|
77
|
+
@primitives << Primitive.new(name, rel(dir), covers)
|
|
78
|
+
duplication_files(dir).each do |file|
|
|
79
|
+
each_block(file) { |block, path| duplication_block(name, block, path) }
|
|
80
|
+
end
|
|
81
|
+
stray_files(dir).each do |file|
|
|
82
|
+
each_block(file) { |block, path| misplaced(block, path, "found in a stray file; use README.md or duplication.md") if CONTENT.include?(block.info) }
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
|
|
86
|
+
def stray_files(dir)
|
|
87
|
+
known = [dir.join("README.md"), dir.join("duplication.md")].map(&:to_s)
|
|
88
|
+
markdown_files(dir).reject { |f| known.include?(f.to_s) || f.to_s.start_with?(dir.join("duplication/").to_s) }
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def markdown_files(dir)
|
|
92
|
+
Dir.glob("**/*.md", base: dir.to_s).sort.map { |f| dir.join(f) }.reject { |f| f.symlink? || !f.file? }
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def readme_block(covers, block, path)
|
|
96
|
+
case block.info
|
|
97
|
+
when "covers" then covers.concat(references(block, path))
|
|
98
|
+
when "parallel", "settings" then misplaced(block, path, "belongs in duplication.md")
|
|
99
|
+
end
|
|
100
|
+
end
|
|
101
|
+
|
|
102
|
+
def misplaced(block, path, message)
|
|
103
|
+
@errors << ContractError.new(path, block.line, "#{block.info} block #{message}")
|
|
104
|
+
end
|
|
105
|
+
|
|
106
|
+
def check_root_files(base)
|
|
107
|
+
base.children.select { |f| real_file?(f) }.select { |f| f.extname == ".md" }.sort_by(&:to_s).each do |file|
|
|
108
|
+
each_block(file) do |block, path|
|
|
109
|
+
misplaced(block, path, "found at the contract root; Contract content must live in a primitive directory") if CONTENT.include?(block.info)
|
|
110
|
+
end
|
|
111
|
+
end
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def duplication_files(dir)
|
|
115
|
+
files = []
|
|
116
|
+
single = dir.join("duplication.md")
|
|
117
|
+
files << single if real_file?(single)
|
|
118
|
+
nested = dir.join("duplication")
|
|
119
|
+
files.concat(markdown_files(nested)) if real_dir?(nested)
|
|
120
|
+
files.sort_by(&:to_s)
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
def each_block(file)
|
|
124
|
+
path = rel(file)
|
|
125
|
+
text = File.read(file, encoding: "UTF-8")
|
|
126
|
+
return @errors << ContractError.new(path, 1, "file is not valid UTF-8") unless text.valid_encoding?
|
|
127
|
+
|
|
128
|
+
Markdown.blocks(text).each { |block| yield block, path }
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
def duplication_block(primitive, block, path)
|
|
132
|
+
case block.info
|
|
133
|
+
when "parallel" then add_clause(primitive, block, path)
|
|
134
|
+
when "settings" then add_settings(primitive, block, path)
|
|
135
|
+
when "covers" then misplaced(block, path, "belongs in README.md")
|
|
136
|
+
end
|
|
137
|
+
end
|
|
138
|
+
|
|
139
|
+
def references(block, path)
|
|
140
|
+
block.body.filter_map do |text, no|
|
|
141
|
+
ref = Markdown.strip_comment(text)
|
|
142
|
+
Reference.new(ref, path, no) unless ref.empty?
|
|
143
|
+
end
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def add_clause(primitive, block, path)
|
|
147
|
+
refs = references(block, path)
|
|
148
|
+
return @errors << ContractError.new(path, block.line, "empty parallel block") if refs.empty?
|
|
149
|
+
|
|
150
|
+
key = Digest::SHA256.hexdigest("#{primitive}|parallel|#{refs.map(&:text).sort.join("\n")}")
|
|
151
|
+
@clauses << Clause.new(primitive, :parallel, path, block.line, block.heading, block.reason, refs, key)
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
def add_settings(primitive, block, path)
|
|
155
|
+
if @settings.key?(primitive)
|
|
156
|
+
return @errors << ContractError.new(path, block.line, "second settings block for #{primitive}")
|
|
157
|
+
end
|
|
158
|
+
|
|
159
|
+
@settings[primitive] = block.body.each_with_object({}) do |(text, no), acc|
|
|
160
|
+
line = Markdown.strip_comment(text)
|
|
161
|
+
next if line.empty?
|
|
162
|
+
|
|
163
|
+
parse_setting(line, path, no, acc)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
|
|
167
|
+
def parse_setting(line, path, no, acc)
|
|
168
|
+
key, value = line.split(":", 2).map(&:strip)
|
|
169
|
+
sym = SETTING_KEYS[key]
|
|
170
|
+
return @errors << ContractError.new(path, no, "unknown setting: #{key}") unless sym
|
|
171
|
+
|
|
172
|
+
parsed = setting_value(sym, value)
|
|
173
|
+
return @errors << ContractError.new(path, no, "bad value for #{key}: #{value.inspect}") if parsed.nil?
|
|
174
|
+
|
|
175
|
+
return @errors << ContractError.new(path, no, "repeated setting: #{key}") if acc.key?(sym)
|
|
176
|
+
|
|
177
|
+
acc[sym] = parsed
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def setting_value(sym, value)
|
|
181
|
+
if sym == :threshold
|
|
182
|
+
f = Float(value, exception: false)
|
|
183
|
+
f if f && f > 0 && f <= 1
|
|
184
|
+
else
|
|
185
|
+
i = Integer(value.to_s, 10, exception: false)
|
|
186
|
+
i if i && i >= 1
|
|
187
|
+
end
|
|
188
|
+
end
|
|
189
|
+
|
|
190
|
+
def rel(path)
|
|
191
|
+
Pathname.new(path.to_s).relative_path_from(@root).to_s
|
|
192
|
+
end
|
|
193
|
+
end
|
|
194
|
+
end
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
require "fileutils"
|
|
5
|
+
require "json"
|
|
6
|
+
require "securerandom"
|
|
7
|
+
require "tmpdir"
|
|
8
|
+
require "prism"
|
|
9
|
+
require "herb"
|
|
10
|
+
require_relative "../version"
|
|
11
|
+
require_relative "../errors"
|
|
12
|
+
require_relative "../git"
|
|
13
|
+
require_relative "../source_files"
|
|
14
|
+
require_relative "../units/ruby"
|
|
15
|
+
require_relative "../units/erb"
|
|
16
|
+
require_relative "../contract"
|
|
17
|
+
require_relative "normalizer"
|
|
18
|
+
require_relative "fingerprints"
|
|
19
|
+
require_relative "index"
|
|
20
|
+
require_relative "matcher"
|
|
21
|
+
require_relative "gate"
|
|
22
|
+
|
|
23
|
+
module Exhale
|
|
24
|
+
module Dry
|
|
25
|
+
DEFAULTS = { threshold: Rational(80, 100), min_lines: 4, min_nodes: 20 }.freeze
|
|
26
|
+
|
|
27
|
+
# Everything one tree yields: its units, their index, the Contract read
|
|
28
|
+
# from the same tree, and every match over the threshold.
|
|
29
|
+
class Sweep
|
|
30
|
+
attr_reader :root, :units, :parse_errors, :contract, :resolver, :index, :matches
|
|
31
|
+
|
|
32
|
+
def initialize(root, files: nil, include_tests: false, contract_dir: "contract", overrides: {})
|
|
33
|
+
@root = root
|
|
34
|
+
@files = files
|
|
35
|
+
@include_tests = include_tests
|
|
36
|
+
@contract_dir = contract_dir
|
|
37
|
+
@overrides = overrides
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def run
|
|
41
|
+
@parse_errors = []
|
|
42
|
+
@units = read_units
|
|
43
|
+
@contract = Contract.load(@root, dir: @contract_dir)
|
|
44
|
+
@resolver = Contract::Resolver.new(@contract, @units)
|
|
45
|
+
@index = Index.new(entries)
|
|
46
|
+
@matches = Matcher.new(@index, floors: floors, settings_for_pair: method(:settings_for_pair)).matches
|
|
47
|
+
self
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def settings_for_pair(a, b)
|
|
51
|
+
return DEFAULTS.merge(@overrides) unless @overrides.empty?
|
|
52
|
+
|
|
53
|
+
exact(@resolver.settings_for_pair(a, b, DEFAULTS))
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
private
|
|
57
|
+
|
|
58
|
+
# A file that can't be read, or isn't UTF-8, is a parse error like any
|
|
59
|
+
# other: the gate never passes code it didn't read.
|
|
60
|
+
def read_units
|
|
61
|
+
SourceFiles.list(@root, files: @files, include_tests: @include_tests).flat_map do |path, language|
|
|
62
|
+
source = read_source(path)
|
|
63
|
+
language == :ruby ? Units::Ruby.extract(source, path) : Units::Erb.extract(source, path)
|
|
64
|
+
rescue ParseError => e
|
|
65
|
+
@parse_errors << e
|
|
66
|
+
[]
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def read_source(path)
|
|
71
|
+
source = File.read(File.join(@root, path), encoding: "UTF-8")
|
|
72
|
+
return source if source.valid_encoding?
|
|
73
|
+
|
|
74
|
+
line = source.each_line.find_index { |text| !text.valid_encoding? }.to_i + 1
|
|
75
|
+
raise ParseError.new(path, line, "isn't valid UTF-8")
|
|
76
|
+
rescue SystemCallError, IOError => e
|
|
77
|
+
raise ParseError.new(path, 1, "can't be read: #{e.message}")
|
|
78
|
+
end
|
|
79
|
+
|
|
80
|
+
def entries
|
|
81
|
+
@units.each_with_index.map do |unit, id|
|
|
82
|
+
tree = Fingerprints.build(Normalizer.normalize(unit))
|
|
83
|
+
Entry.new(id: id, unit: unit, tree: tree, set: tree.digests)
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
|
|
87
|
+
# Candidates are generated at the loosest floors any primitive asks
|
|
88
|
+
# for; each pair is then judged by its own settings.
|
|
89
|
+
def floors
|
|
90
|
+
all = [DEFAULTS.merge(@overrides)]
|
|
91
|
+
all.concat(@contract.settings.values.map { |settings| DEFAULTS.merge(settings) }) if @overrides.empty?
|
|
92
|
+
{ min_lines: all.map { |s| s[:min_lines] }.min, min_nodes: all.map { |s| s[:min_nodes] }.min,
|
|
93
|
+
threshold: all.map { |s| Rational(s[:threshold].to_s) }.min }
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
# Contract thresholds arrive as Floats; scores are Rationals. Reading
|
|
97
|
+
# the Float's decimal text keeps 0.75 exactly 3/4.
|
|
98
|
+
def exact(settings)
|
|
99
|
+
settings.merge(threshold: Rational(settings[:threshold].to_s))
|
|
100
|
+
end
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
# The duplication check: sweeps the tree on disk, sweeps the merge base
|
|
104
|
+
# for labels, and hands both to the Gate for the verdict.
|
|
105
|
+
class Check
|
|
106
|
+
def initialize(root:, base: nil, include_tests: false, contract_dir: "contract", overrides: {},
|
|
107
|
+
introduced_only: false, paths: [], cache_dir: nil)
|
|
108
|
+
@root = File.expand_path(root)
|
|
109
|
+
@base_ref = base
|
|
110
|
+
@include_tests = include_tests
|
|
111
|
+
@contract_dir = contract_dir
|
|
112
|
+
@overrides = overrides
|
|
113
|
+
@introduced_only = introduced_only
|
|
114
|
+
@paths = paths
|
|
115
|
+
@cache_dir = cache_dir || File.join(@root, "tmp", "exhale")
|
|
116
|
+
@git = Git.new(@root)
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def run
|
|
120
|
+
base_sha = validate!
|
|
121
|
+
head = Sweep.new(@root, files: (@git.files if @git.repo?), include_tests: @include_tests,
|
|
122
|
+
contract_dir: @contract_dir, overrides: @overrides).run
|
|
123
|
+
Gate.new(head: head, base: base_sha && base_summary(base_sha), base_sha: base_sha, git: (@git if @git.repo?),
|
|
124
|
+
changed_lines: base_sha ? @git.changed_lines(base_sha) : {},
|
|
125
|
+
introduced_only: @introduced_only, paths: @paths, overrides: @overrides).verdict
|
|
126
|
+
end
|
|
127
|
+
|
|
128
|
+
# Checks the root and the base before anything is read, and returns
|
|
129
|
+
# the base's SHA (nil when there's no base to label against). The CLI
|
|
130
|
+
# calls it for explain too, so a typo there fails the same way.
|
|
131
|
+
def validate!
|
|
132
|
+
raise Error, "--root #{@root} isn't a directory" unless File.directory?(@root)
|
|
133
|
+
|
|
134
|
+
find_base
|
|
135
|
+
end
|
|
136
|
+
|
|
137
|
+
private
|
|
138
|
+
|
|
139
|
+
# A base the caller named and git can't find is an error: running on
|
|
140
|
+
# unlabeled would hide the mistake. A value that reads as an option
|
|
141
|
+
# never reaches git, where `--octopus` would resolve to HEAD.
|
|
142
|
+
def find_base
|
|
143
|
+
raise Error, "--base #{@base_ref} looks like an option, not a commit" if @base_ref&.start_with?("-")
|
|
144
|
+
raise Error, "--base #{@base_ref} needs a git repository, and #{@root} isn't in one" if @base_ref && !@git.repo?
|
|
145
|
+
return unless @git.repo?
|
|
146
|
+
|
|
147
|
+
ref = @base_ref || @git.default_branch_ref
|
|
148
|
+
sha = ref && @git.merge_base(ref)
|
|
149
|
+
raise Error, "--base #{@base_ref} names no commit with a merge base with HEAD" if @base_ref && sha.nil?
|
|
150
|
+
|
|
151
|
+
sha
|
|
152
|
+
end
|
|
153
|
+
|
|
154
|
+
# What the Gate needs from the base, cached on disk. The key covers
|
|
155
|
+
# everything that changes a base sweep, so a warm cache can never
|
|
156
|
+
# answer differently from a cold run.
|
|
157
|
+
def base_summary(sha)
|
|
158
|
+
path = File.join(@cache_dir, "base-#{sha}-#{VERSION}-n#{Normalizer::VERSION}-#{cache_key(sha)}.json")
|
|
159
|
+
cached = read_cache(path)
|
|
160
|
+
return cached if cached
|
|
161
|
+
|
|
162
|
+
summary = Dir.mktmpdir("exhale-base") do |dir|
|
|
163
|
+
files = base_files(sha)
|
|
164
|
+
@git.export_files(sha, files, dir)
|
|
165
|
+
# The same overrides as the head, so a pair that only the flags
|
|
166
|
+
# surface is labeled against a base swept the same way.
|
|
167
|
+
Gate.summarize(Sweep.new(dir, files: files, include_tests: @include_tests, contract_dir: @contract_dir,
|
|
168
|
+
overrides: @overrides).run)
|
|
169
|
+
end
|
|
170
|
+
write_cache(path, summary)
|
|
171
|
+
summary
|
|
172
|
+
end
|
|
173
|
+
|
|
174
|
+
def cache_key(sha)
|
|
175
|
+
parts = [sha, VERSION, Normalizer::VERSION, Prism::VERSION, Herb::VERSION, @include_tests, @git.prefix,
|
|
176
|
+
@contract_dir, @overrides.sort.inspect]
|
|
177
|
+
Digest::SHA256.hexdigest(parts.join("\u0000"))[0, 16]
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
# Only the files a sweep can read: source and the Contract.
|
|
181
|
+
def base_files(sha)
|
|
182
|
+
contract = "#{@contract_dir.chomp('/')}/"
|
|
183
|
+
@git.files_at(sha).select { |path| SourceFiles.language(path) || path.start_with?(contract) }
|
|
184
|
+
end
|
|
185
|
+
|
|
186
|
+
# Plain JSON, never Marshal: loading a Marshal dump runs whatever it
|
|
187
|
+
# names. Anything at the path that isn't a regular file, or doesn't
|
|
188
|
+
# hold exactly the shape below, is rebuilt.
|
|
189
|
+
def read_cache(path)
|
|
190
|
+
return unless File.lstat(path).file?
|
|
191
|
+
|
|
192
|
+
BaseCache.load(File.read(path, encoding: "UTF-8"))
|
|
193
|
+
rescue SystemCallError, IOError
|
|
194
|
+
nil
|
|
195
|
+
end
|
|
196
|
+
|
|
197
|
+
# Written beside the target and renamed into place, so a reader never
|
|
198
|
+
# sees half a file. A symlink at the path is left alone: writing
|
|
199
|
+
# through it would overwrite whatever it points at.
|
|
200
|
+
def write_cache(path, summary)
|
|
201
|
+
FileUtils.mkdir_p(File.dirname(path))
|
|
202
|
+
return if File.symlink?(path)
|
|
203
|
+
|
|
204
|
+
temp = "#{path}.#{Process.pid}.#{SecureRandom.hex(4)}.tmp"
|
|
205
|
+
File.open(temp, File::WRONLY | File::CREAT | File::EXCL, 0o644) { |f| f.write(BaseCache.dump(summary)) }
|
|
206
|
+
File.rename(temp, path)
|
|
207
|
+
rescue SystemCallError, IOError
|
|
208
|
+
FileUtils.rm_f(temp) if temp
|
|
209
|
+
nil
|
|
210
|
+
end
|
|
211
|
+
end
|
|
212
|
+
|
|
213
|
+
# The base summary as JSON, checked field by field on the way back in.
|
|
214
|
+
module BaseCache
|
|
215
|
+
FORMAT = 1
|
|
216
|
+
RATIONAL = %r{\A(-?\d+)/(\d+)\z}
|
|
217
|
+
KEYS = %w[format clusters structures pairs clause_keys].freeze
|
|
218
|
+
|
|
219
|
+
module_function
|
|
220
|
+
|
|
221
|
+
def dump(summary)
|
|
222
|
+
JSON.generate(
|
|
223
|
+
"format" => FORMAT,
|
|
224
|
+
"clusters" => summary.clusters.sort.to_h,
|
|
225
|
+
"structures" => summary.structures.sort.to_h { |key, ids| [key, ids.to_a.sort] },
|
|
226
|
+
"pairs" => summary.pairs.map { |row| [*row[0, 2], "#{row[2].numerator}/#{row[2].denominator}", *row[3..]] },
|
|
227
|
+
"clause_keys" => summary.clause_keys.to_a.sort
|
|
228
|
+
)
|
|
229
|
+
end
|
|
230
|
+
|
|
231
|
+
# nil for anything that isn't exactly what dump writes.
|
|
232
|
+
def load(text)
|
|
233
|
+
data = JSON.parse(text)
|
|
234
|
+
return unless data.is_a?(Hash) && data.keys.sort == KEYS.sort && data["format"] == FORMAT
|
|
235
|
+
|
|
236
|
+
clusters = data["clusters"]
|
|
237
|
+
structures = data["structures"]
|
|
238
|
+
pairs = data["pairs"]
|
|
239
|
+
clause_keys = data["clause_keys"]
|
|
240
|
+
return unless string_hash?(clusters) { |id| id.is_a?(Integer) }
|
|
241
|
+
return unless string_hash?(structures) { |ids| ids.is_a?(Array) && ids.all?(Integer) }
|
|
242
|
+
return unless clause_keys.is_a?(Array) && clause_keys.all?(String)
|
|
243
|
+
return unless pairs.is_a?(Array) && pairs.all? { |row| pair?(row) }
|
|
244
|
+
|
|
245
|
+
BaseSummary.new(clusters: clusters, structures: structures, clause_keys: Set.new(clause_keys),
|
|
246
|
+
pairs: pairs.map { |row| [*row[0, 2], rational(row[2]), *row[3..]] })
|
|
247
|
+
rescue JSON::ParserError, EncodingError
|
|
248
|
+
nil
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def string_hash?(value, &valid)
|
|
252
|
+
value.is_a?(Hash) && value.all? { |key, item| key.is_a?(String) && valid.call(item) }
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def pair?(row)
|
|
256
|
+
row.is_a?(Array) && row.size == 7 && row.all?(String) && RATIONAL.match?(row[2]) &&
|
|
257
|
+
!RATIONAL.match(row[2])[2].to_i.zero?
|
|
258
|
+
end
|
|
259
|
+
|
|
260
|
+
def rational(text)
|
|
261
|
+
numerator, denominator = RATIONAL.match(text).captures.map(&:to_i)
|
|
262
|
+
Rational(numerator, denominator)
|
|
263
|
+
end
|
|
264
|
+
end
|
|
265
|
+
end
|
|
266
|
+
end
|