hashira 0.1.0 → 0.3.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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +334 -32
  4. data/lib/hashira/analysis/census.rb +12 -14
  5. data/lib/hashira/analysis/constant_registry.rb +40 -0
  6. data/lib/hashira/analysis/cycle_findings.rb +1 -1
  7. data/lib/hashira/analysis/cycle_search.rb +1 -1
  8. data/lib/hashira/analysis/definitions.rb +1 -1
  9. data/lib/hashira/analysis/finding.rb +6 -2
  10. data/lib/hashira/analysis/graph.rb +3 -3
  11. data/lib/hashira/analysis/namespace_prefix.rb +32 -0
  12. data/lib/hashira/analysis/node_walk.rb +7 -1
  13. data/lib/hashira/analysis/references.rb +2 -2
  14. data/lib/hashira/analysis/sdp_check.rb +1 -1
  15. data/lib/hashira/analysis/type_walk.rb +1 -1
  16. data/lib/hashira/churn.rb +22 -0
  17. data/lib/hashira/ci/accepted.rb +11 -7
  18. data/lib/hashira/ci/baseline.rb +40 -0
  19. data/lib/hashira/ci/diff.rb +15 -0
  20. data/lib/hashira/ci/edge_diff_report.rb +4 -27
  21. data/lib/hashira/ci/finding_diff_report.rb +26 -0
  22. data/lib/hashira/ci/gate.rb +7 -5
  23. data/lib/hashira/ci/improvement.rb +19 -0
  24. data/lib/hashira/ci/ratchet.rb +16 -20
  25. data/lib/hashira/ci/ratchet_report.rb +42 -0
  26. data/lib/hashira/cli/command_line.rb +12 -6
  27. data/lib/hashira/cli/fail_on.rb +4 -2
  28. data/lib/hashira/cli/options.rb +1 -1
  29. data/lib/hashira/cli/run.rb +9 -3
  30. data/lib/hashira/cli/skip.rb +27 -0
  31. data/lib/hashira/cli/usage.rb +10 -5
  32. data/lib/hashira/cli.rb +1 -1
  33. data/lib/hashira/complexity/analyzer.rb +45 -0
  34. data/lib/hashira/complexity/boolean_run.rb +22 -0
  35. data/lib/hashira/complexity/cognitive_score.rb +72 -0
  36. data/lib/hashira/complexity/if_chain.rb +45 -0
  37. data/lib/hashira/complexity/method_finding.rb +52 -0
  38. data/lib/hashira/complexity/method_score.rb +17 -0
  39. data/lib/hashira/complexity/rescue_scan.rb +28 -0
  40. data/lib/hashira/complexity/rollup.rb +22 -0
  41. data/lib/hashira/duplication/analyzer.rb +21 -0
  42. data/lib/hashira/duplication/cluster.rb +26 -0
  43. data/lib/hashira/duplication/clusterer.rb +50 -0
  44. data/lib/hashira/duplication/delta.rb +39 -0
  45. data/lib/hashira/duplication/duplication_finding.rb +30 -0
  46. data/lib/hashira/duplication/extractor.rb +31 -0
  47. data/lib/hashira/duplication/fragment.rb +42 -0
  48. data/lib/hashira/duplication/grouping.rb +24 -0
  49. data/lib/hashira/duplication/index.rb +35 -0
  50. data/lib/hashira/duplication/maximal.rb +21 -0
  51. data/lib/hashira/duplication/near_miss.rb +33 -0
  52. data/lib/hashira/duplication/sequence.rb +36 -0
  53. data/lib/hashira/duplication/similarity.rb +50 -0
  54. data/lib/hashira/duplication/union_find.rb +21 -0
  55. data/lib/hashira/duplication/variance.rb +57 -0
  56. data/lib/hashira/hotspots/file_cost.rb +19 -0
  57. data/lib/hashira/hotspots/rollup.rb +33 -0
  58. data/lib/hashira/pipeline.rb +28 -6
  59. data/lib/hashira/project.rb +32 -9
  60. data/lib/hashira/report/complexity_table.rb +40 -0
  61. data/lib/hashira/report/dependency_map.rb +8 -5
  62. data/lib/hashira/report/finding_lines.rb +1 -1
  63. data/lib/hashira/report/graph_payload.rb +29 -0
  64. data/lib/hashira/report/hotspot_table.rb +41 -0
  65. data/lib/hashira/report/json.rb +24 -17
  66. data/lib/hashira/report/metrics_table.rb +1 -2
  67. data/lib/hashira/report/text.rb +24 -14
  68. data/lib/hashira/report/view.rb +7 -0
  69. data/lib/hashira/version.rb +1 -1
  70. data/lib/hashira.rb +38 -1
  71. metadata +46 -7
  72. data/lib/hashira/analysis/root_namespace.rb +0 -14
@@ -0,0 +1,36 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class Sequence
6
+ MIN_STATEMENTS = 1
7
+ MAX_STATEMENTS = 12
8
+ LIST_RUN = 3
9
+
10
+ def initialize(file, statements)
11
+ @file = file
12
+ @statements = statements
13
+ end
14
+
15
+ def fragments = segments.flat_map { windows(it) }
16
+
17
+ private
18
+
19
+ def segments = runs.chunk { listing?(it) }.filter_map { |listed, group| group.flatten(1) unless listed }
20
+
21
+ def listing?(run) = run.size >= LIST_RUN
22
+
23
+ def runs = shaped.slice_when { |left, right| left.last != right.last }.map { it.map(&:first) }
24
+
25
+ def shaped = @statements.map { [it, fragment([it]).types] }
26
+
27
+ def windows(segment) = lengths(segment).flat_map { |length| slide(segment, length) }
28
+
29
+ def lengths(segment) = MIN_STATEMENTS..[segment.size, MAX_STATEMENTS].min
30
+
31
+ def slide(segment, length) = (0..(segment.size - length)).map { fragment(segment[it, length]) }
32
+
33
+ def fragment(roots) = Fragment.new(@file, roots)
34
+ end
35
+ end
36
+ end
@@ -0,0 +1,50 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class Similarity
6
+ def initialize(left, right)
7
+ @left = left
8
+ @right = right
9
+ end
10
+
11
+ def ratio
12
+ return 0.0 if @left.empty? || @right.empty?
13
+
14
+ normalized(lcs)
15
+ end
16
+
17
+ def at_least?(threshold) = upper_bound >= threshold && ratio >= threshold
18
+
19
+ private
20
+
21
+ def upper_bound = normalized(tokens_in_common)
22
+
23
+ def normalized(length) = (2.0 * length) / (@left.size + @right.size)
24
+
25
+ def tokens_in_common
26
+ counts = @right.tally
27
+ @left.count { taken?(counts, it) }
28
+ end
29
+
30
+ def taken?(counts, token)
31
+ return false unless counts.fetch(token, 0).positive?
32
+
33
+ counts[token] -= 1
34
+ true
35
+ end
36
+
37
+ def lcs = @left.reduce(blank) { |prev, token| next_row(prev, token) }.last
38
+
39
+ def blank = Array.new(@right.size + 1, 0)
40
+
41
+ def next_row(prev, token)
42
+ @right.each_index.reduce([0]) { |row, index| row << cell(prev, row, token, index) }
43
+ end
44
+
45
+ def cell(prev, row, token, index)
46
+ @right[index] == token ? prev[index] + 1 : [prev[index + 1], row[index]].max
47
+ end
48
+ end
49
+ end
50
+ end
@@ -0,0 +1,21 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class UnionFind
6
+ def initialize
7
+ @parent = {}
8
+ end
9
+
10
+ def union(left, right) = @parent[root(left)] = root(right)
11
+
12
+ def clusters = @parent.keys.group_by { root(it) }.values
13
+
14
+ def root(node)
15
+ @parent[node] = node unless @parent.key?(node)
16
+ found = @parent[node]
17
+ found == node ? node : (@parent[node] = root(found))
18
+ end
19
+ end
20
+ end
21
+ end
@@ -0,0 +1,57 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Duplication
5
+ class Variance
6
+ LITERALS = %i[integer_node float_node string_node symbol_node].freeze
7
+ VALUED = %i[integer_node float_node].freeze
8
+ NAMED = %i[call_node constant_read_node constant_path_node
9
+ local_variable_read_node local_variable_write_node
10
+ instance_variable_read_node instance_variable_write_node].freeze
11
+ CONSTANTS = %i[constant_read_node constant_path_node].freeze
12
+
13
+ def initialize(canonical, other)
14
+ @canonical = canonical
15
+ @other = other
16
+ end
17
+
18
+ def kinds
19
+ return [:structure] if @canonical.types != @other.types
20
+
21
+ differing.map { |node| category(node) }.uniq
22
+ end
23
+
24
+ def shape_only?
25
+ return false unless @canonical.types == @other.types
26
+
27
+ named.any? && named.all? { |left, right| left.name != right.name }
28
+ end
29
+
30
+ private
31
+
32
+ def pairs = @canonical.nodes.zip(@other.nodes)
33
+
34
+ def named = @named ||= pairs.select { |left, _| NAMED.include?(left.type) }
35
+
36
+ def differing = pairs.select { |pair| varies?(*pair) }.map(&:first)
37
+
38
+ def varies?(left, right) = signature(left) != signature(right)
39
+
40
+ def category(node)
41
+ type = node.type
42
+ return :literal if LITERALS.include?(type)
43
+
44
+ CONSTANTS.include?(type) ? :constant : :message
45
+ end
46
+
47
+ def signature(node)
48
+ type = node.type
49
+ return literal(node) if LITERALS.include?(type)
50
+
51
+ node.name if NAMED.include?(type)
52
+ end
53
+
54
+ def literal(node) = VALUED.include?(node.type) ? node.value : node.unescaped
55
+ end
56
+ end
57
+ end
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Hotspots
5
+ CHURN_FLOOR = 1
6
+
7
+ FileCost = Data.define(:file, :cognitive, :duplication, :churn) do
8
+ def cost = cognitive + duplication
9
+
10
+ def rank = cost * effective_churn
11
+
12
+ def effective_churn = [churn, CHURN_FLOOR].max
13
+
14
+ def to_h = { file:, cognitive:, duplication:, churn:, cost:, rank: }
15
+
16
+ def cells = [file, cognitive, duplication, churn, rank]
17
+ end
18
+ end
19
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Hotspots
5
+ class Rollup
6
+ def initialize(complexity, duplication, churn)
7
+ @complexity = complexity
8
+ @duplication = duplication
9
+ @churn = churn
10
+ end
11
+
12
+ def files = costs.reject { it.cost.zero? }.sort_by { [-it.rank, it.file] }
13
+
14
+ private
15
+
16
+ def costs = (cognitive.keys | duplicated.keys).map { cost_for(it) }
17
+
18
+ def cost_for(file)
19
+ FileCost.new(file:, cognitive: cognitive[file], duplication: duplicated[file], churn: @churn.hits(file))
20
+ end
21
+
22
+ def scores = @complexity ? @complexity.methods : []
23
+
24
+ def clusters = @duplication ? @duplication.clusters : []
25
+
26
+ def cognitive = @cognitive ||= per_file(scores.map { [it.file, it.cognitive] })
27
+
28
+ def duplicated = @duplicated ||= per_file(clusters.flat_map(&:site_masses))
29
+
30
+ def per_file(charges) = charges.each_with_object(Hash.new(0)) { |(file, cost), total| total[file] += cost }
31
+ end
32
+ end
33
+ end
@@ -4,21 +4,43 @@ require "prism"
4
4
 
5
5
  module Hashira
6
6
  class Pipeline
7
- def initialize(project)
7
+ ANALYZERS = %i[coupling complexity duplication].freeze
8
+
9
+ RULES = [Analysis::CycleFindings, Analysis::SdpViolationFindings].freeze
10
+
11
+ def initialize(project, enabled: ANALYZERS)
8
12
  @project = project
9
- trees = project.files.to_h { [_1, parse(_1)] }
10
- @census = Analysis::Census.new(project, trees)
11
- @graph = Analysis::Graph.new(project, trees, @census)
13
+ @enabled = enabled
14
+ @trees = project.files.to_h { [it, parse(it)] }
15
+ @graph = Analysis::Graph.new(project, @trees, Analysis::Census.new(project, @trees))
12
16
  end
13
17
 
14
18
  attr_reader :project, :graph
15
19
 
16
- RULES = [Analysis::CycleFindings, Analysis::SdpViolationFindings].freeze
20
+ def complexity
21
+ @complexity ||= Complexity::Analyzer.new(@project, @trees) if enabled?(:complexity)
22
+ end
17
23
 
18
- def findings = RULES.flat_map { _1.new(@project, @graph).list }
24
+ def duplication
25
+ @duplication ||= Duplication::Analyzer.new(@project, @trees, churn) if enabled?(:duplication)
26
+ end
27
+
28
+ def hotspots
29
+ @hotspots ||= Hotspots::Rollup.new(complexity, duplication, churn) if complexity || duplication
30
+ end
31
+
32
+ def churn = @churn ||= Churn.from_git
33
+
34
+ def enabled?(analyzer) = @enabled.include?(analyzer)
35
+
36
+ def findings = coupling_findings + listed(complexity) + listed(duplication)
19
37
 
20
38
  private
21
39
 
40
+ def coupling_findings = enabled?(:coupling) ? RULES.flat_map { it.new(@project, @graph).list } : []
41
+
42
+ def listed(analyzer) = analyzer ? analyzer.findings : []
43
+
22
44
  def parse(path)
23
45
  Prism.parse_file(path).value
24
46
  rescue SystemCallError => error
@@ -5,28 +5,46 @@ module Hashira
5
5
  ROOT_PACKAGE = "(root)"
6
6
 
7
7
  def self.detect(directories)
8
- return new(directories) unless directories.empty?
8
+ targets = directories.empty? ? default_directories : directories
9
+ new(targets.map { descend(it.delete_suffix("/")) })
10
+ end
9
11
 
10
- subdirectories = Dir["lib/*/"].map { File.basename(_1) }
11
- raise Error, "no lib/ directory here — pass the source directory explicitly" if subdirectories.empty?
12
+ def self.default_directories
13
+ raise Error, "no lib/ directory here — pass the source directory explicitly" if Dir["lib/*/"].empty?
12
14
 
13
- new(subdirectories.size == 1 ? ["lib/#{subdirectories.first}"] : ["lib"])
15
+ ["lib"]
14
16
  end
17
+ private_class_method :default_directories
18
+
19
+ def self.descend(directory)
20
+ child = only_subdirectory(directory)
21
+ return directory unless child && Dir["#{child}/*/"].any? && (Dir["#{directory}/*.rb"] - ["#{child}.rb"]).empty?
22
+
23
+ descend(child)
24
+ end
25
+
26
+ def self.only_subdirectory(directory)
27
+ subdirectories = Dir["#{directory}/*/"]
28
+ subdirectories.size == 1 ? subdirectories.first.delete_suffix("/") : nil
29
+ end
30
+ private_class_method :descend, :only_subdirectory
15
31
 
16
32
  def initialize(directories)
17
- missing = directories.reject { Dir.exist?(_1) }
33
+ missing = directories.reject { Dir.exist?(it) }
18
34
  raise Error, "no such directory: #{missing.join(", ")}" unless missing.empty?
19
35
 
20
- @directories = directories.map { _1.delete_suffix("/") }
36
+ @directories = directories.map { it.delete_suffix("/") }
21
37
  end
22
38
 
23
39
  attr_reader :directories
24
40
 
25
- def files = @directories.flat_map { Dir["#{_1}/**/*.rb"] }.sort
41
+ def files = @directories.flat_map { Dir["#{it}/**/*.rb"] }.sort
26
42
 
27
43
  def package_for(path)
28
44
  first, rest = relative(path).delete_suffix(".rb").split("/", 2)
29
- rest || folder?(path, first) ? first : ROOT_PACKAGE
45
+ return ROOT_PACKAGE unless rest || folder?(path, first)
46
+
47
+ contested.include?(first) ? "#{directory_of(path)}/#{first}" : first
30
48
  end
31
49
 
32
50
  def relative(path) = path.delete_prefix("#{directory_of(path)}/")
@@ -39,8 +57,13 @@ module Hashira
39
57
 
40
58
  def folder?(path, name) = Dir.exist?("#{directory_of(path)}/#{name}")
41
59
 
60
+ def contested
61
+ @contested ||= @directories.flat_map { |directory| Dir["#{directory}/*/"].map { File.basename(it) } }
62
+ .tally.filter_map { |name, count| name if count > 1 }
63
+ end
64
+
42
65
  def directory_of(path)
43
- @directories.find { path.start_with?("#{_1}/") } ||
66
+ @directories.find { path.start_with?("#{it}/") } ||
44
67
  raise(Error, "#{path} is outside the analyzed directories")
45
68
  end
46
69
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Report
5
+ class ComplexityTable
6
+ TOP = 10
7
+ METHOD_ROW = "%-44s %4s %6s %s"
8
+ CLASS_ROW = "%-32s %5s %8s %6s"
9
+
10
+ def initialize(complexity, io: $stdout)
11
+ @complexity = complexity
12
+ @io = io
13
+ end
14
+
15
+ def print
16
+ section(@complexity.methods, METHOD_ROW, %w[method Cog Calls Loc],
17
+ "Cognitive complexity — worst methods (Cog = how hard to read, Calls = message sends)")
18
+ section(@complexity.classes, CLASS_ROW, %w[class Cog Methods Peak],
19
+ "Per-class rollup (Cog total survives extract-method; Peak is the worst method it hides)")
20
+ end
21
+
22
+ private
23
+
24
+ def section(scores, row_format, columns, title)
25
+ heading(row_format, columns, title)
26
+ ranked(scores).each { @io.puts format(row_format, *it.cells) }
27
+ @io.puts
28
+ end
29
+
30
+ def heading(row_format, columns, title)
31
+ header = format(row_format, *columns)
32
+ @io.puts "#{title}:\n\n"
33
+ @io.puts header
34
+ @io.puts "-" * header.length
35
+ end
36
+
37
+ def ranked(scores) = scores.select { it.cognitive.positive? }.first(TOP)
38
+ end
39
+ end
40
+ end
@@ -10,18 +10,21 @@ module Hashira
10
10
 
11
11
  def print
12
12
  @io.puts "Dependencies (DependsUpon(refs) -> | <- UsedBy):"
13
- @graph.packages.sort.each { @io.puts row(_1) }
13
+ @graph.packages.sort.each { @io.puts row(it) }
14
14
  end
15
15
 
16
16
  private
17
17
 
18
18
  def row(package)
19
- depends_upon = @graph.dependencies_of(package).map { "#{_1}(#{@graph.weight(package, _1)})" }.join(", ")
20
- used_by = @graph.dependents_of(package).join(", ")
21
19
  format(" %-12s -> %-32s <- %s", package,
22
- depends_upon.empty? ? "(none)" : depends_upon,
23
- used_by.empty? ? "(none)" : used_by)
20
+ list(depends_upon(package)), list(@graph.dependents_of(package)))
24
21
  end
22
+
23
+ def depends_upon(package)
24
+ @graph.dependencies_of(package).map { "#{it}(#{@graph.weight(package, it)})" }
25
+ end
26
+
27
+ def list(items) = items.empty? ? "(none)" : items.join(", ")
25
28
  end
26
29
  end
27
30
  end
@@ -13,7 +13,7 @@ module Hashira
13
13
 
14
14
  def print
15
15
  @io.puts "#{@indent}#{@finding.kind}: #{@finding.message}"
16
- @finding.evidence.first(SAMPLE).each { @io.puts "#{@indent} · #{_1}" }
16
+ @finding.evidence.first(SAMPLE).each { @io.puts "#{@indent} · #{it}" }
17
17
  end
18
18
 
19
19
  def print_with_overflow
@@ -0,0 +1,29 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Report
5
+ class GraphPayload
6
+ def initialize(graph)
7
+ @graph = graph
8
+ end
9
+
10
+ def to_h = { packages:, edges: }
11
+
12
+ private
13
+
14
+ def packages
15
+ @graph.metrics.sort_by { |_package, metric| metric.instability }
16
+ .to_h do |package, metric|
17
+ [package,
18
+ metric.to_h.merge(cyclic: @graph.cyclic?(package))]
19
+ end
20
+ end
21
+
22
+ def edges
23
+ @graph.weighted_edges.map do |from, to, weight|
24
+ { from:, to:, weight:, refs: @graph.evidence_for(from, to).to_a.sort }
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,41 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Report
5
+ class HotspotTable
6
+ TOP = 10
7
+ ROW = "%-46s %5s %5s %6s %7s"
8
+
9
+ def initialize(hotspots, io: $stdout)
10
+ @hotspots = hotspots
11
+ @io = io
12
+ end
13
+
14
+ def print
15
+ return if ranked.empty?
16
+
17
+ heading
18
+ rows
19
+ legend
20
+ end
21
+
22
+ private
23
+
24
+ def ranked = @ranked ||= @hotspots.files.first(TOP)
25
+
26
+ def rows = ranked.each { @io.puts format(ROW, *it.cells) }
27
+
28
+ def heading
29
+ @io.puts "Hotspots — cost × churn (where refactoring pays the most):\n\n"
30
+ header = format(ROW, *%w[file Cog Dup Churn Rank])
31
+ @io.puts header
32
+ @io.puts "-" * header.length
33
+ end
34
+
35
+ def legend
36
+ @io.puts "\nLegend: Cog cognitive complexity, Dup mass of the clones the file carries,"
37
+ @io.puts " Churn commits touching it, Rank (Cog+Dup) × Churn\n\n"
38
+ end
39
+ end
40
+ end
41
+ end
@@ -5,37 +5,44 @@ require "json"
5
5
  module Hashira
6
6
  module Report
7
7
  class Json
8
- def initialize(graph, findings, io: $stdout)
9
- @graph = graph
10
- @findings = findings
8
+ def initialize(view, io: $stdout)
9
+ @view = view
11
10
  @io = io
12
11
  end
13
12
 
14
13
  def print
15
- @io.puts JSON.pretty_generate(packages:, edges:, findings: @findings.all.map(&:to_h),
16
- accepted: accepted_entries)
14
+ @io.puts JSON.pretty_generate(payload)
17
15
  0
18
16
  end
19
17
 
20
18
  private
21
19
 
22
- def accepted_entries
23
- @findings.accepted.map { |finding, reason| finding.to_h.merge(reason:) }
20
+ def payload = base.merge(graph_payload).merge(sections.compact)
21
+
22
+ def base = { findings: @view.findings.all.map(&:to_h), accepted: accepted_entries }
23
+
24
+ def graph_payload
25
+ graph = @view.graph
26
+ graph ? GraphPayload.new(graph).to_h : {}
24
27
  end
25
28
 
26
- def packages
27
- @graph.metrics.sort_by { |_package, metric| metric.instability }
28
- .to_h do |package, metric|
29
- [package,
30
- metric.to_h.merge(cyclic: @graph.cyclic?(package))]
31
- end
29
+ def sections
30
+ { complexity: (complexity_payload if @view.complexity),
31
+ duplication: (duplication_payload if @view.duplication),
32
+ hotspots: @view.hotspots&.files&.map(&:to_h) }
32
33
  end
33
34
 
34
- def edges
35
- @graph.weighted_edges.map do |from, to, weight|
36
- { from:, to:, weight:, refs: @graph.evidence_for(from, to).to_a.sort }
37
- end
35
+ def accepted_entries
36
+ @view.findings.accepted.map { |finding, reason| finding.to_h.merge(reason:) }
38
37
  end
38
+
39
+ def complexity_payload = { methods: method_scores, classes: class_scores }
40
+
41
+ def method_scores = @view.complexity.methods.map(&:to_h)
42
+
43
+ def class_scores = @view.complexity.classes.map(&:to_h)
44
+
45
+ def duplication_payload = @view.duplication.clusters.map { Duplication::Delta.new(it).to_h }
39
46
  end
40
47
  end
41
48
  end
@@ -25,8 +25,7 @@ module Hashira
25
25
 
26
26
  def row(package, metric)
27
27
  cyclic = @graph.cyclic?(package) ? "YES" : "-"
28
- format("%-12s %3d %3d %3d %5.2f %-3s", package,
29
- metric.type_count, metric.afferent, metric.efferent, metric.instability, cyclic)
28
+ format("%-12s %3d %3d %3d %5.2f %-3s", package, *metric.to_h.values, cyclic)
30
29
  end
31
30
 
32
31
  def legend
@@ -3,27 +3,37 @@
3
3
  module Hashira
4
4
  module Report
5
5
  class Text
6
- def initialize(project, graph, findings, io: $stdout)
7
- @project = project
8
- @graph = graph
9
- @findings = findings
6
+ def initialize(view, io: $stdout)
7
+ @view = view
10
8
  @io = io
11
9
  end
12
10
 
13
11
  def print
14
- header
15
- MetricsTable.new(@graph, io: @io).print
16
- DependencyMap.new(@graph, io: @io).print
12
+ coupling_sections if @view.graph
13
+ complexity_section if @view.complexity
14
+ hotspot_section if @view.hotspots
17
15
  findings_section
18
16
  0
19
17
  end
20
18
 
21
19
  private
22
20
 
23
- def header
24
- packages = @graph.packages.size
25
- @io.puts "Package (layer) metrics for #{@project.label} " \
26
- "(#{packages} packages, #{@project.files.size} files)\n\n"
21
+ def coupling_sections
22
+ graph = @view.graph
23
+ header(graph)
24
+ MetricsTable.new(graph, io: @io).print
25
+ DependencyMap.new(graph, io: @io).print
26
+ end
27
+
28
+ def complexity_section = ComplexityTable.new(@view.complexity, io: @io).print
29
+
30
+ def hotspot_section = HotspotTable.new(@view.hotspots, io: @io).print
31
+
32
+ def header(graph)
33
+ project = @view.project
34
+ packages = graph.packages.size
35
+ @io.puts "Package (layer) metrics for #{project.label} " \
36
+ "(#{packages} packages, #{project.files.size} files)\n\n"
27
37
  single_package_note if packages == 1
28
38
  end
29
39
 
@@ -33,7 +43,7 @@ module Hashira
33
43
  end
34
44
 
35
45
  def findings_section
36
- all = @findings.all
46
+ all = @view.findings.all
37
47
  @io.puts "\nFindings (#{all.size}):"
38
48
  print_findings(all)
39
49
  accepted_section
@@ -43,11 +53,11 @@ module Hashira
43
53
  def print_findings(all)
44
54
  return @io.puts " none ✓ — structure is healthy" if all.empty?
45
55
 
46
- all.each { FindingLines.new(_1, indent: " ", io: @io).print_with_overflow }
56
+ all.each { FindingLines.new(it, indent: " ", io: @io).print_with_overflow }
47
57
  end
48
58
 
49
59
  def accepted_section
50
- accepted = @findings.accepted
60
+ accepted = @view.findings.accepted
51
61
  return if accepted.empty?
52
62
 
53
63
  @io.puts "\nAccepted (#{accepted.size}):"
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Report
5
+ View = Data.define(:project, :graph, :complexity, :duplication, :hotspots, :findings)
6
+ end
7
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashira
4
- VERSION = "0.1.0"
4
+ VERSION = "0.3.0"
5
5
  end