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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +97 -0
- data/README.md +334 -32
- data/lib/hashira/analysis/census.rb +12 -14
- data/lib/hashira/analysis/constant_registry.rb +40 -0
- data/lib/hashira/analysis/cycle_findings.rb +1 -1
- data/lib/hashira/analysis/cycle_search.rb +1 -1
- data/lib/hashira/analysis/definitions.rb +1 -1
- data/lib/hashira/analysis/finding.rb +6 -2
- data/lib/hashira/analysis/graph.rb +3 -3
- data/lib/hashira/analysis/namespace_prefix.rb +32 -0
- data/lib/hashira/analysis/node_walk.rb +7 -1
- data/lib/hashira/analysis/references.rb +2 -2
- data/lib/hashira/analysis/sdp_check.rb +1 -1
- data/lib/hashira/analysis/type_walk.rb +1 -1
- data/lib/hashira/churn.rb +22 -0
- data/lib/hashira/ci/accepted.rb +11 -7
- data/lib/hashira/ci/baseline.rb +40 -0
- data/lib/hashira/ci/diff.rb +15 -0
- data/lib/hashira/ci/edge_diff_report.rb +4 -27
- data/lib/hashira/ci/finding_diff_report.rb +26 -0
- data/lib/hashira/ci/gate.rb +7 -5
- data/lib/hashira/ci/improvement.rb +19 -0
- data/lib/hashira/ci/ratchet.rb +16 -20
- data/lib/hashira/ci/ratchet_report.rb +42 -0
- data/lib/hashira/cli/command_line.rb +12 -6
- data/lib/hashira/cli/fail_on.rb +4 -2
- data/lib/hashira/cli/options.rb +1 -1
- data/lib/hashira/cli/run.rb +9 -3
- data/lib/hashira/cli/skip.rb +27 -0
- data/lib/hashira/cli/usage.rb +10 -5
- data/lib/hashira/cli.rb +1 -1
- data/lib/hashira/complexity/analyzer.rb +45 -0
- data/lib/hashira/complexity/boolean_run.rb +22 -0
- data/lib/hashira/complexity/cognitive_score.rb +72 -0
- data/lib/hashira/complexity/if_chain.rb +45 -0
- data/lib/hashira/complexity/method_finding.rb +52 -0
- data/lib/hashira/complexity/method_score.rb +17 -0
- data/lib/hashira/complexity/rescue_scan.rb +28 -0
- data/lib/hashira/complexity/rollup.rb +22 -0
- data/lib/hashira/duplication/analyzer.rb +21 -0
- data/lib/hashira/duplication/cluster.rb +26 -0
- data/lib/hashira/duplication/clusterer.rb +50 -0
- data/lib/hashira/duplication/delta.rb +39 -0
- data/lib/hashira/duplication/duplication_finding.rb +30 -0
- data/lib/hashira/duplication/extractor.rb +31 -0
- data/lib/hashira/duplication/fragment.rb +42 -0
- data/lib/hashira/duplication/grouping.rb +24 -0
- data/lib/hashira/duplication/index.rb +35 -0
- data/lib/hashira/duplication/maximal.rb +21 -0
- data/lib/hashira/duplication/near_miss.rb +33 -0
- data/lib/hashira/duplication/sequence.rb +36 -0
- data/lib/hashira/duplication/similarity.rb +50 -0
- data/lib/hashira/duplication/union_find.rb +21 -0
- data/lib/hashira/duplication/variance.rb +57 -0
- data/lib/hashira/hotspots/file_cost.rb +19 -0
- data/lib/hashira/hotspots/rollup.rb +33 -0
- data/lib/hashira/pipeline.rb +28 -6
- data/lib/hashira/project.rb +32 -9
- data/lib/hashira/report/complexity_table.rb +40 -0
- data/lib/hashira/report/dependency_map.rb +8 -5
- data/lib/hashira/report/finding_lines.rb +1 -1
- data/lib/hashira/report/graph_payload.rb +29 -0
- data/lib/hashira/report/hotspot_table.rb +41 -0
- data/lib/hashira/report/json.rb +24 -17
- data/lib/hashira/report/metrics_table.rb +1 -2
- data/lib/hashira/report/text.rb +24 -14
- data/lib/hashira/report/view.rb +7 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +38 -1
- metadata +46 -7
- 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
|
data/lib/hashira/pipeline.rb
CHANGED
|
@@ -4,21 +4,43 @@ require "prism"
|
|
|
4
4
|
|
|
5
5
|
module Hashira
|
|
6
6
|
class Pipeline
|
|
7
|
-
|
|
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
|
-
|
|
10
|
-
@
|
|
11
|
-
@graph = Analysis::Graph.new(project, trees, @
|
|
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
|
-
|
|
20
|
+
def complexity
|
|
21
|
+
@complexity ||= Complexity::Analyzer.new(@project, @trees) if enabled?(:complexity)
|
|
22
|
+
end
|
|
17
23
|
|
|
18
|
-
def
|
|
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
|
data/lib/hashira/project.rb
CHANGED
|
@@ -5,28 +5,46 @@ module Hashira
|
|
|
5
5
|
ROOT_PACKAGE = "(root)"
|
|
6
6
|
|
|
7
7
|
def self.detect(directories)
|
|
8
|
-
|
|
8
|
+
targets = directories.empty? ? default_directories : directories
|
|
9
|
+
new(targets.map { descend(it.delete_suffix("/")) })
|
|
10
|
+
end
|
|
9
11
|
|
|
10
|
-
|
|
11
|
-
raise Error, "no lib/ directory here — pass the source directory explicitly" if
|
|
12
|
+
def self.default_directories
|
|
13
|
+
raise Error, "no lib/ directory here — pass the source directory explicitly" if Dir["lib/*/"].empty?
|
|
12
14
|
|
|
13
|
-
|
|
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?(
|
|
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 {
|
|
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["#{
|
|
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)
|
|
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?("#{
|
|
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(
|
|
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
|
|
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} · #{
|
|
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
|
data/lib/hashira/report/json.rb
CHANGED
|
@@ -5,37 +5,44 @@ require "json"
|
|
|
5
5
|
module Hashira
|
|
6
6
|
module Report
|
|
7
7
|
class Json
|
|
8
|
-
def initialize(
|
|
9
|
-
@
|
|
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(
|
|
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
|
|
23
|
-
|
|
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
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
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
|
|
35
|
-
@
|
|
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
|
data/lib/hashira/report/text.rb
CHANGED
|
@@ -3,27 +3,37 @@
|
|
|
3
3
|
module Hashira
|
|
4
4
|
module Report
|
|
5
5
|
class Text
|
|
6
|
-
def initialize(
|
|
7
|
-
@
|
|
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
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
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(
|
|
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}):"
|
data/lib/hashira/version.rb
CHANGED