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.
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 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}"
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
@@ -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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Exhale
4
- VERSION = "0.1.0"
4
+ VERSION = "0.2.0"
5
5
  end
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.1.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-03 00:00:00.000000000 Z
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 gates the exhale of every pull request in a Rails app. Its first
84
- check, exhale dry, sweeps the whole codebase for duplicated Ruby and ERB, scores
85
- near-copies by rarity-weighted structural similarity, and fails the build while
86
- any copy is undeclared. Deliberate duplication is declared in the Contract, next
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: 'The contraction gate for Rails: no PR merges while the codebase holds duplication
151
- the Contract doesn''t keep'
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: []