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
data/lib/exhale/report.rb
CHANGED
|
@@ -3,23 +3,31 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
require_relative "version"
|
|
5
5
|
require_relative "dry/normalizer"
|
|
6
|
+
require_relative "complexity/check"
|
|
6
7
|
|
|
7
8
|
module Exhale
|
|
8
9
|
# Renders a duplication check's result as text for the agent doing the
|
|
9
10
|
# exhale, JSON for tools, or EDN in the shape dryer writes to
|
|
10
|
-
# .metrics/dry.edn.
|
|
11
|
+
# .metrics/dry.edn. A complexity check's result renders the same three
|
|
12
|
+
# ways, its EDN in the shape crapper writes.
|
|
11
13
|
module Report
|
|
12
14
|
FORMATS = %w[text json edn].freeze
|
|
15
|
+
COMPLEXITY_LABELS = %i[raised introduced kept warning contracted gone].freeze
|
|
16
|
+
TOP_LINES = 5
|
|
13
17
|
LABELS = { introduced: "INTRODUCED", shifted: "SHIFTED", already_there: "ALREADY THERE", found: "FOUND" }.freeze
|
|
14
18
|
|
|
15
19
|
module_function
|
|
16
20
|
|
|
17
21
|
def render(result, format)
|
|
22
|
+
raise ArgumentError, "unknown format #{format.inspect}" unless FORMATS.include?(format)
|
|
23
|
+
|
|
24
|
+
text, json, edn = if result.is_a?(Complexity::Result) then [ComplexityText, ComplexityJson, ComplexityEdn]
|
|
25
|
+
else [Text, Json, Edn]
|
|
26
|
+
end
|
|
18
27
|
case format
|
|
19
|
-
when "text" then
|
|
20
|
-
when "json" then JSON.pretty_generate(
|
|
21
|
-
|
|
22
|
-
else raise ArgumentError, "unknown format #{format.inspect}"
|
|
28
|
+
when "text" then text.new(result).render
|
|
29
|
+
when "json" then JSON.pretty_generate(json.new(result).to_h)
|
|
30
|
+
else edn.new(result).render
|
|
23
31
|
end
|
|
24
32
|
end
|
|
25
33
|
|
|
@@ -39,6 +47,30 @@ module Exhale
|
|
|
39
47
|
value.to_f.round(2)
|
|
40
48
|
end
|
|
41
49
|
|
|
50
|
+
# One unit's score with every point, for `exhale complexity explain`.
|
|
51
|
+
def explain(scored)
|
|
52
|
+
unit = scored.unit
|
|
53
|
+
out = ["#{unit.identity} #{unit.path}:#{unit.start_line}-#{unit.end_line}",
|
|
54
|
+
"score #{scored.score}, #{scored.meta} metaprogramming"]
|
|
55
|
+
width = scored.points.map { |p| p.construct.size }.max.to_i
|
|
56
|
+
scored.points.each do |p|
|
|
57
|
+
out << " line #{p.line.to_s.ljust(5)} +#{p.increment} #{p.construct.ljust(width)} nesting #{p.nesting}"
|
|
58
|
+
end
|
|
59
|
+
"#{out.join("\n")}\n"
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def complexity_counts(result)
|
|
63
|
+
counts = Hash.new(0)
|
|
64
|
+
result.rows.each { |row| counts[row.label] += 1 }
|
|
65
|
+
counts
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
# [[line, points earned there, constructs]], worst first.
|
|
69
|
+
def worst_lines(points)
|
|
70
|
+
points.group_by(&:line).map { |line, at| [line, at.sum(&:increment), at.map(&:construct).uniq] }
|
|
71
|
+
.sort_by { |line, total, _| [-total, line] }.first(TOP_LINES)
|
|
72
|
+
end
|
|
73
|
+
|
|
42
74
|
class Text
|
|
43
75
|
def initialize(result)
|
|
44
76
|
@result = result
|
|
@@ -199,5 +231,138 @@ module Exhale
|
|
|
199
231
|
"{:file #{l.path.inspect}, :start-line #{l.start_line}, :end-line #{l.end_line}}"
|
|
200
232
|
end
|
|
201
233
|
end
|
|
234
|
+
|
|
235
|
+
# Shares the duplication report's error lines and places.
|
|
236
|
+
class ComplexityText < Text
|
|
237
|
+
HEADINGS = { raised: "RAISED", introduced: "INTRODUCED", warning: "OVER THE FLOOR" }.freeze
|
|
238
|
+
CONTRACT_FAILING = "FAILS UNDER THE CONTRACT'S FLOORS"
|
|
239
|
+
|
|
240
|
+
def render
|
|
241
|
+
@out << header
|
|
242
|
+
@result.notes.each { |note| @out << "note: #{note}" }
|
|
243
|
+
@result.rows.select { |row| HEADINGS.key?(row.label) }.sort_by { |row| order(row) }.each { |row| unit(row) }
|
|
244
|
+
# Under --floor these units pass in the report but fail the run.
|
|
245
|
+
@result.contract_failing.each { |row| unit(row, CONTRACT_FAILING) }
|
|
246
|
+
kept
|
|
247
|
+
errors
|
|
248
|
+
@out << ""
|
|
249
|
+
@out << "#{Report.complexity_counts(@result)[:contracted]} contracted"
|
|
250
|
+
"#{@out.join("\n")}\n"
|
|
251
|
+
end
|
|
252
|
+
|
|
253
|
+
private
|
|
254
|
+
|
|
255
|
+
def header
|
|
256
|
+
counts = Report.complexity_counts(@result)
|
|
257
|
+
parts = ["#{@result.rows.count(&:head)} units"]
|
|
258
|
+
COMPLEXITY_LABELS.each { |label| parts << "#{counts[label]} #{label}" if counts[label].positive? }
|
|
259
|
+
parts << plural(@result.clause_errors.size, "contract error") unless @result.clause_errors.empty?
|
|
260
|
+
parts << plural(@result.parse_errors.size, "parse error") unless @result.parse_errors.empty?
|
|
261
|
+
parts << (@result.exit_code.zero? ? "clean" : "failing")
|
|
262
|
+
base = @result.base_sha ? " base #{@result.base_sha[0, 7]}" : ""
|
|
263
|
+
floors = @result.floors.map { |name, floor| ", #{name} #{floor}" }.join
|
|
264
|
+
"exhale complexity: #{parts.join(', ')}#{base} floor #{@result.floor}#{floors}"
|
|
265
|
+
end
|
|
266
|
+
|
|
267
|
+
def order(row)
|
|
268
|
+
[HEADINGS.keys.index(row.label), -row.head.score, row.unit.path, row.unit.start_line]
|
|
269
|
+
end
|
|
270
|
+
|
|
271
|
+
def unit(row, heading = HEADINGS.fetch(row.label))
|
|
272
|
+
head = row.head
|
|
273
|
+
@out << ""
|
|
274
|
+
@out << "#{heading} #{head.unit.identity} #{scores(row)}"
|
|
275
|
+
@out << " #{place(head.unit)}"
|
|
276
|
+
@out << " was #{row.base.unit.identity} at #{place(row.base.unit)}" if row.match == :shape
|
|
277
|
+
Report.worst_lines(head.points).each do |line, total, constructs|
|
|
278
|
+
@out << " line #{line.to_s.ljust(5)} +#{total.to_s.ljust(3)} #{constructs.join(', ')}"
|
|
279
|
+
end
|
|
280
|
+
@out << " hint #{hint(row)}"
|
|
281
|
+
end
|
|
282
|
+
|
|
283
|
+
def scores(row)
|
|
284
|
+
limit = row.ceiling ? "ceiling #{row.ceiling.max}" : "floor #{row.floor}"
|
|
285
|
+
from = row.base ? "#{row.base.score} -> " : ""
|
|
286
|
+
"#{from}#{row.head.score} (#{limit})"
|
|
287
|
+
end
|
|
288
|
+
|
|
289
|
+
def hint(row)
|
|
290
|
+
limit = row.ceiling ? [row.ceiling.max, row.floor].max : row.floor
|
|
291
|
+
case row.label
|
|
292
|
+
when :raised
|
|
293
|
+
"bring it back to #{[row.base.score, limit].max} or less: pull the worst lines into named methods"
|
|
294
|
+
when :introduced then "split it until every piece scores #{limit} or less"
|
|
295
|
+
else "it was already over the floor; nothing is compared without a base"
|
|
296
|
+
end
|
|
297
|
+
end
|
|
298
|
+
|
|
299
|
+
def kept
|
|
300
|
+
@result.rows.select { |row| row.label == :kept }.each do |row|
|
|
301
|
+
clause = row.ceiling
|
|
302
|
+
@out << ""
|
|
303
|
+
@out << "KEPT #{row.head.unit.identity} #{scores(row)} #{clause.path}:#{clause.line} \"#{clause.heading}\""
|
|
304
|
+
end
|
|
305
|
+
end
|
|
306
|
+
end
|
|
307
|
+
|
|
308
|
+
class ComplexityJson
|
|
309
|
+
def initialize(result)
|
|
310
|
+
@result = result
|
|
311
|
+
end
|
|
312
|
+
|
|
313
|
+
def to_h
|
|
314
|
+
{
|
|
315
|
+
"exhale" => VERSION,
|
|
316
|
+
"check" => "complexity",
|
|
317
|
+
"base" => @result.base_sha,
|
|
318
|
+
"floor" => @result.floor,
|
|
319
|
+
"floors" => @result.floors,
|
|
320
|
+
"exit_code" => @result.exit_code,
|
|
321
|
+
"counts" => Report.complexity_counts(@result).transform_keys(&:to_s),
|
|
322
|
+
"notes" => @result.notes,
|
|
323
|
+
"units" => @result.rows.map { |row| row(row) },
|
|
324
|
+
"contract_failing" => @result.contract_failing.map { |row| row.unit.identity },
|
|
325
|
+
"contract_errors" => @result.clause_errors.map { |e| error(e) },
|
|
326
|
+
"parse_errors" => @result.parse_errors.map { |e| error(e) }
|
|
327
|
+
}
|
|
328
|
+
end
|
|
329
|
+
|
|
330
|
+
private
|
|
331
|
+
|
|
332
|
+
def row(row)
|
|
333
|
+
side(row.head || row.base).merge(
|
|
334
|
+
"label" => row.label.to_s, "score" => row.head&.score, "base_score" => row.base&.score,
|
|
335
|
+
"base" => row.head && row.base ? side(row.base).slice("identity", "path", "start_line", "end_line") : nil,
|
|
336
|
+
"match" => row.match&.to_s, "similarity" => row.similarity&.to_f&.round(2), "floor" => row.floor,
|
|
337
|
+
"ceiling" => row.ceiling && row.ceiling.to_h.slice(:path, :line, :max).transform_keys(&:to_s)
|
|
338
|
+
)
|
|
339
|
+
end
|
|
340
|
+
|
|
341
|
+
def side(scored)
|
|
342
|
+
unit = scored.unit
|
|
343
|
+
{ "identity" => unit.identity, "kind" => unit.kind.to_s, "path" => unit.path,
|
|
344
|
+
"start_line" => unit.start_line, "end_line" => unit.end_line, "metaprogramming" => scored.meta,
|
|
345
|
+
"points" => scored.points.map { |p| p.to_h.transform_keys(&:to_s) } }
|
|
346
|
+
end
|
|
347
|
+
|
|
348
|
+
def error(e)
|
|
349
|
+
{ "path" => e.path, "line" => e.line, "message" => e.message }
|
|
350
|
+
end
|
|
351
|
+
end
|
|
352
|
+
|
|
353
|
+
# The entries crapper writes, so uml-viewer can read them.
|
|
354
|
+
class ComplexityEdn
|
|
355
|
+
def initialize(result)
|
|
356
|
+
@result = result
|
|
357
|
+
end
|
|
358
|
+
|
|
359
|
+
def render
|
|
360
|
+
rows = @result.rows.select(&:head).map do |row|
|
|
361
|
+
unit = row.head.unit
|
|
362
|
+
"{:name #{unit.name.inspect}, :namespace #{unit.namespace.inspect}, :complexity #{row.head.score}}"
|
|
363
|
+
end
|
|
364
|
+
"{:entries\n [#{rows.join("\n ")}]}\n"
|
|
365
|
+
end
|
|
366
|
+
end
|
|
202
367
|
end
|
|
203
368
|
end
|
data/lib/exhale/units.rb
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "errors"
|
|
4
|
+
require_relative "source_files"
|
|
5
|
+
require_relative "units/ruby"
|
|
6
|
+
require_relative "units/erb"
|
|
7
|
+
|
|
8
|
+
module Exhale
|
|
9
|
+
# The units of a tree, read from the files SourceFiles lists.
|
|
10
|
+
module Units
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
# [units, parse errors]. A file that can't be read, or isn't UTF-8, is a
|
|
14
|
+
# parse error like any other: no gate passes code it didn't read.
|
|
15
|
+
def read(root, files: nil, include_tests: false)
|
|
16
|
+
errors = []
|
|
17
|
+
units = SourceFiles.list(root, files: files, include_tests: include_tests).flat_map do |path, language|
|
|
18
|
+
source = source(root, path)
|
|
19
|
+
language == :ruby ? Ruby.extract(source, path) : Erb.extract(source, path)
|
|
20
|
+
rescue ParseError => e
|
|
21
|
+
errors << e
|
|
22
|
+
[]
|
|
23
|
+
end
|
|
24
|
+
[units, errors]
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def source(root, path)
|
|
28
|
+
source = File.read(File.join(root, path), encoding: "UTF-8")
|
|
29
|
+
return source if source.valid_encoding?
|
|
30
|
+
|
|
31
|
+
line = source.each_line.find_index { |text| !text.valid_encoding? }.to_i + 1
|
|
32
|
+
raise ParseError.new(path, line, "isn't valid UTF-8")
|
|
33
|
+
rescue SystemCallError, IOError => e
|
|
34
|
+
raise ParseError.new(path, 1, "can't be read: #{e.message}")
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
data/lib/exhale/version.rb
CHANGED
data/lib/exhale.rb
CHANGED
|
@@ -8,6 +8,7 @@ require_relative "exhale/git"
|
|
|
8
8
|
require_relative "exhale/source_files"
|
|
9
9
|
require_relative "exhale/units/ruby"
|
|
10
10
|
require_relative "exhale/units/erb"
|
|
11
|
+
require_relative "exhale/units"
|
|
11
12
|
require_relative "exhale/contract"
|
|
12
13
|
require_relative "exhale/dry/normalizer/ruby"
|
|
13
14
|
require_relative "exhale/dry/normalizer/erb"
|
|
@@ -17,6 +18,9 @@ require_relative "exhale/dry/index"
|
|
|
17
18
|
require_relative "exhale/dry/matcher"
|
|
18
19
|
require_relative "exhale/dry/gate"
|
|
19
20
|
require_relative "exhale/dry/check"
|
|
21
|
+
require_relative "exhale/complexity/cognitive"
|
|
22
|
+
require_relative "exhale/complexity/ratchet"
|
|
23
|
+
require_relative "exhale/complexity/check"
|
|
20
24
|
require_relative "exhale/report"
|
|
21
25
|
require_relative "exhale/cli"
|
|
22
26
|
|
metadata
CHANGED
|
@@ -1,14 +1,14 @@
|
|
|
1
1
|
--- !ruby/object:Gem::Specification
|
|
2
2
|
name: exhale
|
|
3
3
|
version: !ruby/object:Gem::Version
|
|
4
|
-
version: 0.
|
|
4
|
+
version: 0.2.0
|
|
5
5
|
platform: ruby
|
|
6
6
|
authors:
|
|
7
7
|
- Obie Fernandez
|
|
8
8
|
autorequire:
|
|
9
9
|
bindir: exe
|
|
10
10
|
cert_chain: []
|
|
11
|
-
date: 2026-10-
|
|
11
|
+
date: 2026-10-07 00:00:00.000000000 Z
|
|
12
12
|
dependencies:
|
|
13
13
|
- !ruby/object:Gem::Dependency
|
|
14
14
|
name: bigdecimal
|
|
@@ -80,11 +80,11 @@ dependencies:
|
|
|
80
80
|
- - "~>"
|
|
81
81
|
- !ruby/object:Gem::Version
|
|
82
82
|
version: '13.0'
|
|
83
|
-
description: exhale
|
|
84
|
-
check, exhale dry, sweeps the whole codebase for duplicated
|
|
85
|
-
near-copies by rarity-weighted structural similarity, and fails
|
|
86
|
-
any copy is undeclared. Deliberate duplication is declared in the
|
|
87
|
-
to the reason for it. Built on Prism and Herb.
|
|
83
|
+
description: exhale is the contraction toolkit for the exhale of every pull request
|
|
84
|
+
in a Rails app. Its first check, exhale dry, sweeps the whole codebase for duplicated
|
|
85
|
+
Ruby and ERB, scores near-copies by rarity-weighted structural similarity, and fails
|
|
86
|
+
the build while any copy is undeclared. Deliberate duplication is declared in the
|
|
87
|
+
Contract, next to the reason for it. Built on Prism and Herb.
|
|
88
88
|
email:
|
|
89
89
|
- obiefernandez@gmail.com
|
|
90
90
|
executables:
|
|
@@ -101,6 +101,9 @@ files:
|
|
|
101
101
|
- exhale.gemspec
|
|
102
102
|
- lib/exhale.rb
|
|
103
103
|
- lib/exhale/cli.rb
|
|
104
|
+
- lib/exhale/complexity/check.rb
|
|
105
|
+
- lib/exhale/complexity/cognitive.rb
|
|
106
|
+
- lib/exhale/complexity/ratchet.rb
|
|
104
107
|
- lib/exhale/contract.rb
|
|
105
108
|
- lib/exhale/contract/markdown.rb
|
|
106
109
|
- lib/exhale/contract/reference.rb
|
|
@@ -119,6 +122,7 @@ files:
|
|
|
119
122
|
- lib/exhale/shape.rb
|
|
120
123
|
- lib/exhale/source_files.rb
|
|
121
124
|
- lib/exhale/unit.rb
|
|
125
|
+
- lib/exhale/units.rb
|
|
122
126
|
- lib/exhale/units/erb.rb
|
|
123
127
|
- lib/exhale/units/ruby.rb
|
|
124
128
|
- lib/exhale/version.rb
|
|
@@ -147,6 +151,6 @@ requirements: []
|
|
|
147
151
|
rubygems_version: 3.5.11
|
|
148
152
|
signing_key:
|
|
149
153
|
specification_version: 4
|
|
150
|
-
summary:
|
|
151
|
-
the Contract doesn'
|
|
154
|
+
summary: The contraction toolkit for Rails. Its first check, exhale dry, fails a PR
|
|
155
|
+
while the codebase holds duplication the Contract doesn't keep
|
|
152
156
|
test_files: []
|