exhale 0.1.0 → 0.2.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 +4 -4
- data/CHANGELOG.md +7 -0
- data/README.md +55 -11
- data/exhale.gemspec +3 -3
- data/lib/exhale/cli.rb +96 -43
- data/lib/exhale/complexity/check.rb +173 -0
- data/lib/exhale/complexity/cognitive.rb +260 -0
- data/lib/exhale/complexity/ratchet.rb +181 -0
- data/lib/exhale/contract/markdown.rb +1 -1
- data/lib/exhale/contract.rb +79 -18
- data/lib/exhale/dry/check.rb +2 -26
- data/lib/exhale/report.rb +170 -5
- data/lib/exhale/units.rb +37 -0
- data/lib/exhale/version.rb +1 -1
- data/lib/exhale.rb +4 -0
- metadata +13 -9
|
@@ -0,0 +1,260 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
require_relative "../units/ruby"
|
|
5
|
+
|
|
6
|
+
module Exhale
|
|
7
|
+
module Complexity
|
|
8
|
+
# One increment of a unit's score.
|
|
9
|
+
#
|
|
10
|
+
# line - Where the construct that earned it sits.
|
|
11
|
+
# construct - What earned it: "if", "elsif", "&&", "each block", "send".
|
|
12
|
+
# nesting - The nesting level at the construct.
|
|
13
|
+
# increment - 1, plus the nesting level for the constructs that pay it.
|
|
14
|
+
# meta - true for a metaprogramming point.
|
|
15
|
+
Point = Struct.new(:line, :construct, :nesting, :increment, :meta, keyword_init: true)
|
|
16
|
+
|
|
17
|
+
# G. Ann Campbell's Cognitive Complexity read off the Prism tree, with the
|
|
18
|
+
# Ruby decisions in contract/cognitive/README.md.
|
|
19
|
+
class Cognitive
|
|
20
|
+
ITERATORS = %i[
|
|
21
|
+
each map flat_map collect filter_map select filter reject find detect find_index find_all
|
|
22
|
+
any? all? none? one? count sum inject reduce group_by partition sort_by min_by max_by minmax_by
|
|
23
|
+
uniq zip take_while drop_while chunk_while slice_when times upto downto step loop cycle
|
|
24
|
+
with_index with_object map! collect! select! filter! reject! sort_by!
|
|
25
|
+
].freeze
|
|
26
|
+
|
|
27
|
+
METAPROGRAMMING = %i[
|
|
28
|
+
eval instance_eval class_eval module_eval instance_exec class_exec module_exec
|
|
29
|
+
define_method define_singleton_method send __send__ public_send
|
|
30
|
+
instance_variable_get instance_variable_set const_get
|
|
31
|
+
].freeze
|
|
32
|
+
|
|
33
|
+
def self.points(unit)
|
|
34
|
+
raise ArgumentError, "#{unit.identity} isn't Ruby; templates aren't scored" unless unit.language == :ruby
|
|
35
|
+
|
|
36
|
+
new(unit.node, method_name(unit)).points
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# The name a recursive call would use. A DSL body has none, and neither
|
|
40
|
+
# does a define_method named only by its ordinal.
|
|
41
|
+
def self.method_name(unit)
|
|
42
|
+
return unless unit.kind == :method
|
|
43
|
+
|
|
44
|
+
name = unit.name.sub(/\[\d+\]\z/, "")
|
|
45
|
+
name unless !unit.node.is_a?(Prism::DefNode) && Units::Ruby::DEFINE_METHODS.key?(name.to_sym)
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def initialize(node, name)
|
|
49
|
+
@node = node
|
|
50
|
+
@name = name&.to_sym
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
# Sorted by line, then in the order the walk met them.
|
|
54
|
+
def points
|
|
55
|
+
@points = []
|
|
56
|
+
@recursed = false
|
|
57
|
+
root
|
|
58
|
+
@points.each_with_index.sort_by { |point, index| [point.line, index] }.map(&:first)
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
private
|
|
62
|
+
|
|
63
|
+
# The unit's own def, block or lambda is its body, at nesting 0.
|
|
64
|
+
def root
|
|
65
|
+
flat("method_missing", @node.location.start_line, 0, meta: true) if @name == :method_missing
|
|
66
|
+
visit(@node.parameters, 0)
|
|
67
|
+
visit(@node.body, 0)
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def visit(node, nesting)
|
|
71
|
+
return if node.nil? || node.is_a?(Prism::DefNode)
|
|
72
|
+
|
|
73
|
+
case node
|
|
74
|
+
when Prism::IfNode then if_node(node, nesting)
|
|
75
|
+
when Prism::UnlessNode then unless_node(node, nesting)
|
|
76
|
+
when Prism::WhileNode, Prism::UntilNode then loop_node(node, nesting)
|
|
77
|
+
when Prism::ForNode then for_node(node, nesting)
|
|
78
|
+
when Prism::CaseNode, Prism::CaseMatchNode then case_node(node, nesting)
|
|
79
|
+
when Prism::BeginNode then begin_node(node, nesting)
|
|
80
|
+
when Prism::RescueModifierNode then rescue_modifier(node, nesting)
|
|
81
|
+
when Prism::AndNode, Prism::OrNode then boolean(node, nesting)
|
|
82
|
+
when Prism::CallNode then call(node, nesting)
|
|
83
|
+
when Prism::BlockNode, Prism::LambdaNode then nested_body(node, nesting)
|
|
84
|
+
else children(node, nesting)
|
|
85
|
+
end
|
|
86
|
+
end
|
|
87
|
+
|
|
88
|
+
def children(node, nesting)
|
|
89
|
+
node.compact_child_nodes.each { |child| visit(child, nesting) }
|
|
90
|
+
end
|
|
91
|
+
|
|
92
|
+
def nested(construct, line, nesting)
|
|
93
|
+
@points << Point.new(line: line, construct: construct, nesting: nesting, increment: 1 + nesting, meta: false)
|
|
94
|
+
end
|
|
95
|
+
|
|
96
|
+
def flat(construct, line, nesting, meta: false)
|
|
97
|
+
@points << Point.new(line: line, construct: construct, nesting: nesting, increment: 1, meta: meta)
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# A ternary has no if keyword, and its else costs nothing.
|
|
101
|
+
def if_node(node, nesting)
|
|
102
|
+
ternary = node.if_keyword_loc.nil?
|
|
103
|
+
nested(ternary ? "ternary" : "if", line(node.if_keyword_loc || node.location), nesting)
|
|
104
|
+
visit(node.predicate, nesting)
|
|
105
|
+
visit(node.statements, nesting + 1)
|
|
106
|
+
ternary ? visit(node.subsequent&.statements, nesting + 1) : branches(node.subsequent, nesting)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def branches(branch, nesting)
|
|
110
|
+
case branch
|
|
111
|
+
when Prism::IfNode
|
|
112
|
+
flat("elsif", line(branch.if_keyword_loc), nesting)
|
|
113
|
+
visit(branch.predicate, nesting)
|
|
114
|
+
visit(branch.statements, nesting + 1)
|
|
115
|
+
branches(branch.subsequent, nesting)
|
|
116
|
+
when Prism::ElseNode
|
|
117
|
+
flat("else", line(branch.else_keyword_loc), nesting)
|
|
118
|
+
visit(branch.statements, nesting + 1)
|
|
119
|
+
end
|
|
120
|
+
end
|
|
121
|
+
|
|
122
|
+
def unless_node(node, nesting)
|
|
123
|
+
nested("unless", line(node.keyword_loc), nesting)
|
|
124
|
+
visit(node.predicate, nesting)
|
|
125
|
+
visit(node.statements, nesting + 1)
|
|
126
|
+
branches(node.else_clause, nesting)
|
|
127
|
+
end
|
|
128
|
+
|
|
129
|
+
def loop_node(node, nesting)
|
|
130
|
+
nested(node.keyword_loc.slice, line(node.keyword_loc), nesting)
|
|
131
|
+
visit(node.predicate, nesting)
|
|
132
|
+
visit(node.statements, nesting + 1)
|
|
133
|
+
end
|
|
134
|
+
|
|
135
|
+
def for_node(node, nesting)
|
|
136
|
+
nested("for", line(node.for_keyword_loc), nesting)
|
|
137
|
+
visit(node.index, nesting)
|
|
138
|
+
visit(node.collection, nesting)
|
|
139
|
+
visit(node.statements, nesting + 1)
|
|
140
|
+
end
|
|
141
|
+
|
|
142
|
+
# One charge for the whole case; its branches and else sit inside it.
|
|
143
|
+
def case_node(node, nesting)
|
|
144
|
+
nested(node.is_a?(Prism::CaseMatchNode) ? "case/in" : "case", line(node.case_keyword_loc), nesting)
|
|
145
|
+
visit(node.predicate, nesting)
|
|
146
|
+
node.conditions.each { |condition| case_branch(condition, nesting + 1) }
|
|
147
|
+
visit(node.else_clause&.statements, nesting + 1)
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# A guard on an `in` branch is an if or unless around the pattern in
|
|
151
|
+
# Prism, but it costs 1 flat.
|
|
152
|
+
def case_branch(condition, nesting)
|
|
153
|
+
guard = condition.pattern if condition.is_a?(Prism::InNode)
|
|
154
|
+
return visit(condition, nesting) unless guard.is_a?(Prism::IfNode) || guard.is_a?(Prism::UnlessNode)
|
|
155
|
+
|
|
156
|
+
keyword = guard.is_a?(Prism::IfNode) ? guard.if_keyword_loc : guard.keyword_loc
|
|
157
|
+
flat("#{keyword.slice} guard", line(keyword), nesting)
|
|
158
|
+
visit(guard.statements, nesting)
|
|
159
|
+
visit(guard.predicate, nesting)
|
|
160
|
+
visit(condition.statements, nesting)
|
|
161
|
+
end
|
|
162
|
+
|
|
163
|
+
# The else of a begin/rescue and its ensure cost nothing and don't nest.
|
|
164
|
+
def begin_node(node, nesting)
|
|
165
|
+
visit(node.statements, nesting)
|
|
166
|
+
clause = node.rescue_clause
|
|
167
|
+
while clause
|
|
168
|
+
nested("rescue", line(clause.keyword_loc), nesting)
|
|
169
|
+
clause.exceptions.each { |exception| visit(exception, nesting) }
|
|
170
|
+
visit(clause.reference, nesting)
|
|
171
|
+
visit(clause.statements, nesting + 1)
|
|
172
|
+
clause = clause.subsequent
|
|
173
|
+
end
|
|
174
|
+
visit(node.else_clause&.statements, nesting)
|
|
175
|
+
visit(node.ensure_clause&.statements, nesting)
|
|
176
|
+
end
|
|
177
|
+
|
|
178
|
+
def rescue_modifier(node, nesting)
|
|
179
|
+
nested("rescue", line(node.keyword_loc), nesting)
|
|
180
|
+
visit(node.expression, nesting)
|
|
181
|
+
visit(node.rescue_expression, nesting + 1)
|
|
182
|
+
end
|
|
183
|
+
|
|
184
|
+
# One point per run of like operators, in source order. `!` and
|
|
185
|
+
# parentheses are see-through, so `a && !(b && c)` is one run.
|
|
186
|
+
def boolean(node, nesting)
|
|
187
|
+
operators = []
|
|
188
|
+
operands = []
|
|
189
|
+
flatten(node, operators, operands)
|
|
190
|
+
operators.each_with_index do |(kind, at), index|
|
|
191
|
+
flat(kind, at, nesting) if index.zero? || operators[index - 1].first != kind
|
|
192
|
+
end
|
|
193
|
+
operands.each { |operand| visit(operand, nesting) }
|
|
194
|
+
end
|
|
195
|
+
|
|
196
|
+
def flatten(node, operators, operands)
|
|
197
|
+
if node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)
|
|
198
|
+
flatten(node.left, operators, operands)
|
|
199
|
+
operators << [node.is_a?(Prism::AndNode) ? "&&" : "||", line(node.operator_loc)]
|
|
200
|
+
flatten(node.right, operators, operands)
|
|
201
|
+
elsif (inner = see_through(node))
|
|
202
|
+
flatten(inner, operators, operands)
|
|
203
|
+
else
|
|
204
|
+
operands << node
|
|
205
|
+
end
|
|
206
|
+
end
|
|
207
|
+
|
|
208
|
+
def see_through(node)
|
|
209
|
+
case node
|
|
210
|
+
when Prism::ParenthesesNode
|
|
211
|
+
body = node.body
|
|
212
|
+
body.is_a?(Prism::StatementsNode) && body.body.size == 1 ? body.body.first : nil
|
|
213
|
+
when Prism::CallNode
|
|
214
|
+
node.receiver if node.name == :! && node.arguments.nil? && node.block.nil?
|
|
215
|
+
end
|
|
216
|
+
end
|
|
217
|
+
|
|
218
|
+
def call(node, nesting)
|
|
219
|
+
at = line(node.message_loc || node.location)
|
|
220
|
+
recursion(node, at, nesting)
|
|
221
|
+
flat(node.name.to_s, at, nesting, meta: true) if METAPROGRAMMING.include?(node.name)
|
|
222
|
+
visit(node.receiver, nesting)
|
|
223
|
+
visit(node.arguments, nesting)
|
|
224
|
+
block(node, nesting)
|
|
225
|
+
end
|
|
226
|
+
|
|
227
|
+
def recursion(node, at, nesting)
|
|
228
|
+
return if @recursed || @name.nil? || node.name != @name
|
|
229
|
+
return unless node.receiver.nil? || node.receiver.is_a?(Prism::SelfNode)
|
|
230
|
+
|
|
231
|
+
@recursed = true
|
|
232
|
+
flat("recursion", at, nesting)
|
|
233
|
+
end
|
|
234
|
+
|
|
235
|
+
# `&:sym` and `&method(:x)` aren't blocks. A define_method block inside
|
|
236
|
+
# a unit is no unit of its own (Units::Ruby stops at a unit), so it
|
|
237
|
+
# scores here like any block.
|
|
238
|
+
def block(node, nesting)
|
|
239
|
+
block = node.block
|
|
240
|
+
return visit(block, nesting) unless block.is_a?(Prism::BlockNode)
|
|
241
|
+
|
|
242
|
+
nested("#{node.name} block", line(block.opening_loc), nesting) if iterates?(node.name)
|
|
243
|
+
nested_body(block, nesting)
|
|
244
|
+
end
|
|
245
|
+
|
|
246
|
+
def nested_body(node, nesting)
|
|
247
|
+
visit(node.parameters, nesting + 1)
|
|
248
|
+
visit(node.body, nesting + 1)
|
|
249
|
+
end
|
|
250
|
+
|
|
251
|
+
def iterates?(name)
|
|
252
|
+
ITERATORS.include?(name) || name.start_with?("each_")
|
|
253
|
+
end
|
|
254
|
+
|
|
255
|
+
def line(location)
|
|
256
|
+
location.start_line
|
|
257
|
+
end
|
|
258
|
+
end
|
|
259
|
+
end
|
|
260
|
+
end
|
|
@@ -0,0 +1,181 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cognitive"
|
|
4
|
+
require_relative "../dry/normalizer"
|
|
5
|
+
require_relative "../dry/fingerprints"
|
|
6
|
+
require_relative "../dry/index"
|
|
7
|
+
|
|
8
|
+
module Exhale
|
|
9
|
+
module Complexity
|
|
10
|
+
# A unit and the points it scored, at the head or at the base.
|
|
11
|
+
Scored = Struct.new(:unit, :points) do
|
|
12
|
+
def self.of(unit)
|
|
13
|
+
new(unit, Cognitive.points(unit))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def score
|
|
17
|
+
points.sum(&:increment)
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def meta
|
|
21
|
+
points.count(&:meta)
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
# One unit's verdict.
|
|
26
|
+
#
|
|
27
|
+
# label - :raised and :introduced fail. :kept rose past the floor
|
|
28
|
+
# within a ceiling. :rose, :new, :unchanged, :contracted and
|
|
29
|
+
# :gone pass. With no base, :warning is over the floor and
|
|
30
|
+
# :scored isn't.
|
|
31
|
+
# head, base - Scored, or nil on the side the unit is missing from.
|
|
32
|
+
# match - :identity or :shape when both sides are there.
|
|
33
|
+
# similarity - The shape score of a :shape match.
|
|
34
|
+
# floor - The floor the unit was judged against.
|
|
35
|
+
# ceiling - The Contract clause that keeps it, or nil.
|
|
36
|
+
Row = Struct.new(:label, :head, :base, :match, :similarity, :floor, :ceiling, keyword_init: true) do
|
|
37
|
+
def failing?
|
|
38
|
+
Ratchet::FAILING.include?(label)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def unit
|
|
42
|
+
(head || base).unit
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
# Judges each head unit against its base unit. Only a rise can fail, so
|
|
47
|
+
# code is never punished for what it already was.
|
|
48
|
+
class Ratchet
|
|
49
|
+
FAILING = %i[raised introduced].freeze
|
|
50
|
+
SIMILARITY = Rational(80, 100)
|
|
51
|
+
# A unit that rose, a new unit, and a unit with no base to compare
|
|
52
|
+
# against: its label over its limit, and at or under it.
|
|
53
|
+
OUTCOMES = { rose: %i[raised rose], new: %i[introduced new], alone: %i[warning scored] }.freeze
|
|
54
|
+
|
|
55
|
+
# head and base are Scored lists in path and line order; base is nil
|
|
56
|
+
# when there's nothing to compare against. floor_for and ceiling_for
|
|
57
|
+
# take a unit.
|
|
58
|
+
def initialize(head:, base:, floor_for:, ceiling_for:)
|
|
59
|
+
@head = head
|
|
60
|
+
@base = base
|
|
61
|
+
@floor_for = floor_for
|
|
62
|
+
@ceiling_for = ceiling_for
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def rows
|
|
66
|
+
return @head.map { |scored| judge(scored) } unless @base
|
|
67
|
+
|
|
68
|
+
pairs = match
|
|
69
|
+
taken = taken(pairs)
|
|
70
|
+
@head.map { |scored| judge(scored, *pairs[scored]) } +
|
|
71
|
+
@base.reject { |base| taken[base] }.map { |base| Row.new(label: :gone, base: base) }
|
|
72
|
+
end
|
|
73
|
+
|
|
74
|
+
private
|
|
75
|
+
|
|
76
|
+
def judge(head, base = nil, match = nil, similarity = nil)
|
|
77
|
+
floor = @floor_for.call(head.unit)
|
|
78
|
+
ceiling = @ceiling_for.call(head.unit)
|
|
79
|
+
Row.new(label: label(head.score, base&.score, floor, ceiling), head: head, base: base, match: match,
|
|
80
|
+
similarity: similarity, floor: floor, ceiling: ceiling)
|
|
81
|
+
end
|
|
82
|
+
|
|
83
|
+
# A ceiling only loosens: its max stands in for the floor when higher.
|
|
84
|
+
def label(score, base_score, floor, ceiling)
|
|
85
|
+
return :contracted if base_score && score < base_score
|
|
86
|
+
return :unchanged if score == base_score
|
|
87
|
+
|
|
88
|
+
over, under = OUTCOMES.fetch(situation(base_score))
|
|
89
|
+
return over if score > (ceiling ? [ceiling.max, floor].max : floor)
|
|
90
|
+
|
|
91
|
+
ceiling && score > floor ? :kept : under
|
|
92
|
+
end
|
|
93
|
+
|
|
94
|
+
def situation(base_score)
|
|
95
|
+
return :alone unless @base
|
|
96
|
+
|
|
97
|
+
base_score ? :rose : :new
|
|
98
|
+
end
|
|
99
|
+
|
|
100
|
+
# {head => [base, how, similarity]}: identity first, then shape.
|
|
101
|
+
def match
|
|
102
|
+
pairs = identity_pairs
|
|
103
|
+
taken = taken(pairs)
|
|
104
|
+
shape_matches(@head.reject { |h| pairs.key?(h) }, @base.reject { |b| taken[b] }).each do |head, base, score|
|
|
105
|
+
pairs[head] = [base, :shape, score]
|
|
106
|
+
end
|
|
107
|
+
pairs
|
|
108
|
+
end
|
|
109
|
+
|
|
110
|
+
def taken(pairs)
|
|
111
|
+
pairs.each_value.with_object({}.compare_by_identity) { |(base, *), taken| taken[base] = true }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def identity_pairs
|
|
115
|
+
pairs = {}.compare_by_identity
|
|
116
|
+
bases = @base.group_by { |scored| scored.unit.identity }
|
|
117
|
+
@head.group_by { |scored| scored.unit.identity }.each do |identity, heads|
|
|
118
|
+
pair_up(heads, bases.fetch(identity, [])).each { |head, base| pairs[head] = [base, :identity, nil] }
|
|
119
|
+
end
|
|
120
|
+
pairs
|
|
121
|
+
end
|
|
122
|
+
|
|
123
|
+
# An identity is unique within a file, so a unit pairs with the one in
|
|
124
|
+
# its own file first; the rest pair in path and line order.
|
|
125
|
+
def pair_up(heads, bases)
|
|
126
|
+
same = heads.filter_map { |head| (base = bases.find { |b| b.unit.path == head.unit.path }) && [head, base] }
|
|
127
|
+
rest = (heads - same.map(&:first)).zip(bases - same.map(&:last))
|
|
128
|
+
same + rest.select(&:last)
|
|
129
|
+
end
|
|
130
|
+
|
|
131
|
+
# Every pair at or over the threshold, best first; ties go to the base
|
|
132
|
+
# unit first in path and line order. Each side matches at most once.
|
|
133
|
+
def shape_matches(heads, bases)
|
|
134
|
+
return [] if heads.empty? || bases.empty?
|
|
135
|
+
|
|
136
|
+
entries = entries(heads + bases)
|
|
137
|
+
index = Dry::Index.new(entries)
|
|
138
|
+
greedy(candidates(index, entries.first(heads.size), entries.drop(heads.size)), heads, bases)
|
|
139
|
+
end
|
|
140
|
+
|
|
141
|
+
def entries(scored)
|
|
142
|
+
scored.each_with_index.map do |each, id|
|
|
143
|
+
tree = Dry::Fingerprints.build(Dry::Normalizer.normalize(each.unit))
|
|
144
|
+
Dry::Entry.new(id: id, unit: each.unit, tree: tree, set: tree.digests)
|
|
145
|
+
end
|
|
146
|
+
end
|
|
147
|
+
|
|
148
|
+
# [[score, base index, head index]] for every pair at the threshold.
|
|
149
|
+
def candidates(index, heads, bases)
|
|
150
|
+
heads.each_with_index.flat_map do |h, hi|
|
|
151
|
+
bases.each_with_index.filter_map { |b, bi| (score = similarity(index, h, b)) && [score, bi, hi] }
|
|
152
|
+
end
|
|
153
|
+
end
|
|
154
|
+
|
|
155
|
+
def similarity(index, a, b)
|
|
156
|
+
return unless close?(a.total, b.total)
|
|
157
|
+
|
|
158
|
+
score = index.score(a.set, a.total, b.set, b.total)
|
|
159
|
+
score if score >= SIMILARITY
|
|
160
|
+
end
|
|
161
|
+
|
|
162
|
+
def greedy(candidates, heads, bases)
|
|
163
|
+
used_heads = {}
|
|
164
|
+
used_bases = {}
|
|
165
|
+
candidates.sort_by { |score, bi, hi| [-score, bi, hi] }.filter_map do |score, bi, hi|
|
|
166
|
+
next if used_heads[hi] || used_bases[bi]
|
|
167
|
+
|
|
168
|
+
used_heads[hi] = used_bases[bi] = true
|
|
169
|
+
[heads[hi], bases[bi], score]
|
|
170
|
+
end
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# Jaccard can't reach the threshold when one side outweighs the other
|
|
174
|
+
# by more than its inverse, so those pairs aren't scored.
|
|
175
|
+
def close?(a, b)
|
|
176
|
+
small, large = [a, b].minmax
|
|
177
|
+
small * SIMILARITY.denominator >= large * SIMILARITY.numerator
|
|
178
|
+
end
|
|
179
|
+
end
|
|
180
|
+
end
|
|
181
|
+
end
|
|
@@ -69,7 +69,7 @@ module Exhale
|
|
|
69
69
|
if closes?(line, fence)
|
|
70
70
|
state[:blocks] << finish(fence)
|
|
71
71
|
state[:fence] = nil
|
|
72
|
-
state[:prose] = [] if %w[parallel settings covers].include?(fence[:info])
|
|
72
|
+
state[:prose] = [] if %w[parallel settings covers ceiling].include?(fence[:info])
|
|
73
73
|
else
|
|
74
74
|
fence[:body] << [line, no]
|
|
75
75
|
end
|
data/lib/exhale/contract.rb
CHANGED
|
@@ -11,23 +11,38 @@ require_relative "contract/resolver"
|
|
|
11
11
|
|
|
12
12
|
module Exhale
|
|
13
13
|
# The Contract: Markdown under <root>/contract/<primitive>/ that declares which
|
|
14
|
-
# code belongs to a primitive
|
|
14
|
+
# code belongs to a primitive, which units are deliberately parallel, and
|
|
15
|
+
# which stay complex up to a ceiling. Each check reads only its own files:
|
|
16
|
+
# duplication.md for dry, complexity.md for complexity.
|
|
15
17
|
class Contract
|
|
16
18
|
Primitive = Struct.new(:name, :path, :covers)
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
19
|
+
# max is a ceiling's limit, nil for a parallel clause.
|
|
20
|
+
Clause = Struct.new(:primitive, :kind, :path, :line, :heading, :reason, :references, :key, :max)
|
|
21
|
+
|
|
22
|
+
CONTENT = %w[covers parallel settings ceiling].freeze
|
|
23
|
+
# The blocks each check's own file holds, and the settings it takes.
|
|
24
|
+
BLOCKS = { dry: %w[parallel settings], complexity: %w[ceiling settings] }.freeze
|
|
25
|
+
SETTING_KEYS = {
|
|
26
|
+
dry: { "threshold" => :threshold, "min-lines" => :min_lines, "min-nodes" => :min_nodes },
|
|
27
|
+
complexity: { "floor" => :floor }
|
|
28
|
+
}.freeze
|
|
29
|
+
HOME = { "parallel" => "duplication.md", "ceiling" => "complexity.md", "covers" => "README.md" }.freeze
|
|
30
|
+
# A floor of 0 is allowed: every rise then fails.
|
|
31
|
+
MINIMUMS = { min_lines: 1, min_nodes: 1, floor: 0 }.freeze
|
|
32
|
+
MAX = /\Amax:\s*(.*)\z/
|
|
21
33
|
|
|
22
34
|
attr_reader :primitives, :clauses, :settings, :errors
|
|
23
35
|
|
|
24
|
-
def self.load(root, dir: "contract")
|
|
25
|
-
new(root, dir).tap(&:parse)
|
|
36
|
+
def self.load(root, dir: "contract", check: :dry)
|
|
37
|
+
new(root, dir, check).tap(&:parse)
|
|
26
38
|
end
|
|
27
39
|
|
|
28
|
-
def initialize(root, dir)
|
|
40
|
+
def initialize(root, dir, check = :dry)
|
|
41
|
+
raise ArgumentError, "unknown check #{check.inspect}" unless BLOCKS.key?(check)
|
|
42
|
+
|
|
29
43
|
@root = Pathname.new(root.to_s)
|
|
30
44
|
@dir = dir
|
|
45
|
+
@check = check
|
|
31
46
|
@primitives = []
|
|
32
47
|
@clauses = []
|
|
33
48
|
@settings = {}
|
|
@@ -75,16 +90,27 @@ module Exhale
|
|
|
75
90
|
readme = dir.join("README.md")
|
|
76
91
|
each_block(readme) { |block, path| readme_block(covers, block, path) } if real_file?(readme)
|
|
77
92
|
@primitives << Primitive.new(name, rel(dir), covers)
|
|
78
|
-
|
|
79
|
-
each_block(file) { |block, path|
|
|
93
|
+
check_files(dir).each do |file|
|
|
94
|
+
each_block(file) { |block, path| check_block(name, block, path) }
|
|
80
95
|
end
|
|
81
96
|
stray_files(dir).each do |file|
|
|
82
|
-
each_block(file)
|
|
97
|
+
each_block(file) do |block, path|
|
|
98
|
+
next unless CONTENT.include?(block.info)
|
|
99
|
+
|
|
100
|
+
misplaced(block, path, "found in a stray file; use README.md, duplication.md or complexity.md")
|
|
101
|
+
end
|
|
83
102
|
end
|
|
84
103
|
end
|
|
85
104
|
|
|
105
|
+
def check_files(dir)
|
|
106
|
+
return duplication_files(dir) if @check == :dry
|
|
107
|
+
|
|
108
|
+
file = dir.join("complexity.md")
|
|
109
|
+
real_file?(file) ? [file] : []
|
|
110
|
+
end
|
|
111
|
+
|
|
86
112
|
def stray_files(dir)
|
|
87
|
-
known = [
|
|
113
|
+
known = %w[README.md duplication.md complexity.md].map { |name| dir.join(name).to_s }
|
|
88
114
|
markdown_files(dir).reject { |f| known.include?(f.to_s) || f.to_s.start_with?(dir.join("duplication/").to_s) }
|
|
89
115
|
end
|
|
90
116
|
|
|
@@ -95,7 +121,8 @@ module Exhale
|
|
|
95
121
|
def readme_block(covers, block, path)
|
|
96
122
|
case block.info
|
|
97
123
|
when "covers" then covers.concat(references(block, path))
|
|
98
|
-
when "parallel", "
|
|
124
|
+
when "parallel", "ceiling" then misplaced(block, path, "belongs in #{HOME[block.info]}")
|
|
125
|
+
when "settings" then misplaced(block, path, "belongs in duplication.md or complexity.md")
|
|
99
126
|
end
|
|
100
127
|
end
|
|
101
128
|
|
|
@@ -128,11 +155,16 @@ module Exhale
|
|
|
128
155
|
Markdown.blocks(text).each { |block| yield block, path }
|
|
129
156
|
end
|
|
130
157
|
|
|
131
|
-
|
|
158
|
+
# A block type that belongs to the other check, or to README.md, is an
|
|
159
|
+
# error where it sits.
|
|
160
|
+
def check_block(primitive, block, path)
|
|
161
|
+
return unless CONTENT.include?(block.info)
|
|
162
|
+
return misplaced(block, path, "belongs in #{HOME[block.info]}") unless BLOCKS[@check].include?(block.info)
|
|
163
|
+
|
|
132
164
|
case block.info
|
|
133
165
|
when "parallel" then add_clause(primitive, block, path)
|
|
134
|
-
when "
|
|
135
|
-
|
|
166
|
+
when "ceiling" then add_ceiling(primitive, block, path)
|
|
167
|
+
else add_settings(primitive, block, path)
|
|
136
168
|
end
|
|
137
169
|
end
|
|
138
170
|
|
|
@@ -151,6 +183,35 @@ module Exhale
|
|
|
151
183
|
@clauses << Clause.new(primitive, :parallel, path, block.line, block.heading, block.reason, refs, key)
|
|
152
184
|
end
|
|
153
185
|
|
|
186
|
+
# A ceiling names units and gives one `max: N` they may reach.
|
|
187
|
+
def add_ceiling(primitive, block, path)
|
|
188
|
+
lines = block.body.map { |text, no| [Markdown.strip_comment(text), no] }.reject { |line, _| line.empty? }
|
|
189
|
+
maxes, names = lines.partition { |line, _| MAX.match?(line) }
|
|
190
|
+
return @errors << ContractError.new(path, block.line, "ceiling block names no unit") if names.empty?
|
|
191
|
+
return unless (max = ceiling_max(maxes, block, path))
|
|
192
|
+
|
|
193
|
+
refs = names.map { |line, no| Reference.new(line, path, no) }
|
|
194
|
+
key = Digest::SHA256.hexdigest("#{primitive}|ceiling|#{max}|#{refs.map(&:text).sort.join("\n")}")
|
|
195
|
+
@clauses << Clause.new(primitive, :ceiling, path, block.line, block.heading, block.reason, refs, key, max)
|
|
196
|
+
end
|
|
197
|
+
|
|
198
|
+
# The one max a ceiling gives, or nil once the reason it has none is
|
|
199
|
+
# recorded.
|
|
200
|
+
def ceiling_max(maxes, block, path)
|
|
201
|
+
return ceiling_error(path, block.line, "ceiling block needs max: N") if maxes.empty?
|
|
202
|
+
return ceiling_error(path, maxes[1].last, "repeated max in a ceiling block") if maxes.size > 1
|
|
203
|
+
|
|
204
|
+
text, no = maxes.first
|
|
205
|
+
value = MAX.match(text)[1]
|
|
206
|
+
max = Integer(value, 10, exception: false)
|
|
207
|
+
max && max >= 0 ? max : ceiling_error(path, no, "bad value for max: #{value.inspect}")
|
|
208
|
+
end
|
|
209
|
+
|
|
210
|
+
def ceiling_error(path, line, message)
|
|
211
|
+
@errors << ContractError.new(path, line, message)
|
|
212
|
+
nil
|
|
213
|
+
end
|
|
214
|
+
|
|
154
215
|
def add_settings(primitive, block, path)
|
|
155
216
|
if @settings.key?(primitive)
|
|
156
217
|
return @errors << ContractError.new(path, block.line, "second settings block for #{primitive}")
|
|
@@ -166,7 +227,7 @@ module Exhale
|
|
|
166
227
|
|
|
167
228
|
def parse_setting(line, path, no, acc)
|
|
168
229
|
key, value = line.split(":", 2).map(&:strip)
|
|
169
|
-
sym = SETTING_KEYS[key]
|
|
230
|
+
sym = SETTING_KEYS[@check][key]
|
|
170
231
|
return @errors << ContractError.new(path, no, "unknown setting: #{key}") unless sym
|
|
171
232
|
|
|
172
233
|
parsed = setting_value(sym, value)
|
|
@@ -183,7 +244,7 @@ module Exhale
|
|
|
183
244
|
f if f && f > 0 && f <= 1
|
|
184
245
|
else
|
|
185
246
|
i = Integer(value.to_s, 10, exception: false)
|
|
186
|
-
i if i && i >=
|
|
247
|
+
i if i && i >= MINIMUMS.fetch(sym)
|
|
187
248
|
end
|
|
188
249
|
end
|
|
189
250
|
|
data/lib/exhale/dry/check.rb
CHANGED
|
@@ -11,8 +11,7 @@ require_relative "../version"
|
|
|
11
11
|
require_relative "../errors"
|
|
12
12
|
require_relative "../git"
|
|
13
13
|
require_relative "../source_files"
|
|
14
|
-
require_relative "../units
|
|
15
|
-
require_relative "../units/erb"
|
|
14
|
+
require_relative "../units"
|
|
16
15
|
require_relative "../contract"
|
|
17
16
|
require_relative "normalizer"
|
|
18
17
|
require_relative "fingerprints"
|
|
@@ -38,8 +37,7 @@ module Exhale
|
|
|
38
37
|
end
|
|
39
38
|
|
|
40
39
|
def run
|
|
41
|
-
@parse_errors =
|
|
42
|
-
@units = read_units
|
|
40
|
+
@units, @parse_errors = Units.read(@root, files: @files, include_tests: @include_tests)
|
|
43
41
|
@contract = Contract.load(@root, dir: @contract_dir)
|
|
44
42
|
@resolver = Contract::Resolver.new(@contract, @units)
|
|
45
43
|
@index = Index.new(entries)
|
|
@@ -55,28 +53,6 @@ module Exhale
|
|
|
55
53
|
|
|
56
54
|
private
|
|
57
55
|
|
|
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
56
|
def entries
|
|
81
57
|
@units.each_with_index.map do |unit, id|
|
|
82
58
|
tree = Fingerprints.build(Normalizer.normalize(unit))
|