hashira 0.6.0 → 0.8.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 +106 -0
- data/README.md +148 -53
- data/exe/hashira +1 -1
- data/lib/hashira/analysis/finding.rb +5 -1
- data/lib/hashira/analysis/syntax.rb +6 -6
- data/lib/hashira/churn.rb +3 -4
- data/lib/hashira/ci/accepted.rb +9 -10
- data/lib/hashira/ci/baseline.rb +39 -12
- data/lib/hashira/ci/comparison.rb +24 -0
- data/lib/hashira/ci/diff.rb +4 -8
- data/lib/hashira/ci/finding_diff_report.rb +10 -0
- data/lib/hashira/ci/gate.rb +10 -2
- data/lib/hashira/ci/improvement.rb +1 -1
- data/lib/hashira/ci/ratchet.rb +41 -10
- data/lib/hashira/ci/ratchet_report.rb +8 -6
- data/lib/hashira/ci/scope.rb +10 -0
- data/lib/hashira/ci/slice.rb +17 -0
- data/lib/hashira/ci/status.rb +7 -0
- data/lib/hashira/ci/sweep.rb +13 -0
- data/lib/hashira/cli/arguments.rb +17 -8
- data/lib/hashira/cli/choice.rb +9 -0
- data/lib/hashira/cli/command_line.rb +8 -5
- data/lib/hashira/cli/fail_on.rb +17 -7
- data/lib/hashira/cli/flag.rb +3 -1
- data/lib/hashira/cli/flags.rb +23 -0
- data/lib/hashira/cli/format.rb +1 -1
- data/lib/hashira/cli/needs.rb +45 -0
- data/lib/hashira/cli/only.rb +16 -0
- data/lib/hashira/cli/options.rb +12 -3
- data/lib/hashira/cli/package_by.rb +1 -1
- data/lib/hashira/cli/run.rb +36 -8
- data/lib/hashira/cli/top.rb +13 -0
- data/lib/hashira/cli/usage.rb +5 -1
- data/lib/hashira/cli.rb +31 -11
- data/lib/hashira/complexity/cognitive_score.rb +44 -33
- data/lib/hashira/complexity/method_finding.rb +7 -5
- data/lib/hashira/complexity/method_score.rb +1 -1
- data/lib/hashira/complexity/scores.rb +11 -9
- data/lib/hashira/coupling/audiences.rb +3 -3
- data/lib/hashira/coupling/catalog.rb +8 -6
- data/lib/hashira/coupling/census.rb +42 -22
- data/lib/hashira/coupling/cycle_search.rb +11 -7
- data/lib/hashira/coupling/definitions.rb +1 -1
- data/lib/hashira/coupling/edge_map.rb +9 -8
- data/lib/hashira/coupling/folding.rb +5 -5
- data/lib/hashira/coupling/graph.rb +16 -9
- data/lib/hashira/coupling/metric.rb +9 -1
- data/lib/hashira/coupling/naming.rb +4 -4
- data/lib/hashira/coupling/references.rb +12 -12
- data/lib/hashira/coupling/report.rb +7 -9
- data/lib/hashira/coupling/roll_call.rb +1 -1
- data/lib/hashira/coupling/roll_call_findings.rb +1 -1
- data/lib/hashira/coupling/roster.rb +37 -12
- data/lib/hashira/coupling/rule.rb +1 -1
- data/lib/hashira/coupling/sdp_violation_findings.rb +3 -1
- data/lib/hashira/diagram/dot.rb +9 -6
- data/lib/hashira/diagram/mermaid.rb +11 -8
- data/lib/hashira/diagram/source.rb +5 -2
- data/lib/hashira/duplication/clones.rb +1 -1
- data/lib/hashira/duplication/clusters.rb +10 -7
- data/lib/hashira/duplication/delta.rb +1 -4
- data/lib/hashira/duplication/duplication_finding.rb +4 -2
- data/lib/hashira/duplication/fragment.rb +2 -2
- data/lib/hashira/duplication/harvest.rb +7 -5
- data/lib/hashira/duplication/index.rb +3 -2
- data/lib/hashira/duplication/similarity.rb +2 -2
- data/lib/hashira/duplication/variance.rb +1 -1
- data/lib/hashira/error.rb +0 -3
- data/lib/hashira/focus.rb +29 -0
- data/lib/hashira/git_log.rb +19 -0
- data/lib/hashira/hotspots/file_cost.rb +1 -1
- data/lib/hashira/hotspots/rollup.rb +2 -2
- data/lib/hashira/pipeline.rb +26 -24
- data/lib/hashira/project.rb +47 -32
- data/lib/hashira/report/columns.rb +54 -0
- data/lib/hashira/report/complexity_table.rb +12 -16
- data/lib/hashira/report/dependency_map.rb +1 -0
- data/lib/hashira/report/graph_payload.rb +1 -1
- data/lib/hashira/report/hotspot_table.rb +6 -14
- data/lib/hashira/report/json.rb +10 -2
- data/lib/hashira/report/metrics_table.rb +19 -20
- data/lib/hashira/report/notices.rb +37 -0
- data/lib/hashira/report/phrases.rb +25 -18
- data/lib/hashira/report/text.rb +24 -9
- data/lib/hashira/report/view.rb +4 -1
- data/lib/hashira/smells/census.rb +3 -4
- data/lib/hashira/smells/check.rb +1 -5
- data/lib/hashira/smells/contexts.rb +4 -4
- data/lib/hashira/smells/control_parameter.rb +1 -1
- data/lib/hashira/smells/data_clump.rb +2 -4
- data/lib/hashira/smells/duplicate_method_call.rb +3 -3
- data/lib/hashira/smells/feature_envy.rb +3 -3
- data/lib/hashira/smells/foreign.rb +14 -14
- data/lib/hashira/smells/instance_variable_assumption.rb +3 -3
- data/lib/hashira/smells/kind.rb +9 -0
- data/lib/hashira/smells/manual_dispatch.rb +1 -1
- data/lib/hashira/smells/module_initialize.rb +0 -2
- data/lib/hashira/smells/nil_check.rb +1 -1
- data/lib/hashira/smells/ownership.rb +19 -7
- data/lib/hashira/smells/param_check.rb +2 -2
- data/lib/hashira/smells/refs.rb +13 -7
- data/lib/hashira/smells/repeated_conditional.rb +1 -3
- data/lib/hashira/smells/report.rb +16 -7
- data/lib/hashira/smells/too_many_instance_variables.rb +3 -3
- data/lib/hashira/smells/visibility.rb +18 -11
- data/lib/hashira/trees.rb +26 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +14 -0
- metadata +16 -1
data/lib/hashira/project.rb
CHANGED
|
@@ -3,54 +3,69 @@
|
|
|
3
3
|
class Hashira::Project
|
|
4
4
|
ROOT_PACKAGE = "(root)"
|
|
5
5
|
|
|
6
|
-
def
|
|
7
|
-
|
|
6
|
+
def initialize(requested)
|
|
7
|
+
@requested = requested
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def
|
|
11
|
-
|
|
12
|
-
|
|
10
|
+
def directories = @_directories ||= resolved
|
|
11
|
+
|
|
12
|
+
def rails? = directories.any? { config?(File.expand_path(it)) }
|
|
13
|
+
|
|
14
|
+
def files = directories.flat_map { Dir["#{it}/**/*.rb"] }.sort
|
|
15
|
+
|
|
16
|
+
def package(path)
|
|
17
|
+
first, rest = relative(path).delete_suffix(".rb").split("/", 2)
|
|
18
|
+
return ROOT_PACKAGE unless rest || folder?(path, first)
|
|
19
|
+
contested.include?(first) ? "#{parent(path)}/#{first}" : first
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def relative(path) = path.delete_prefix("#{parent(path)}/")
|
|
23
|
+
|
|
24
|
+
def label = directories.join(", ")
|
|
25
|
+
|
|
26
|
+
def root = ROOT_PACKAGE
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def resolved
|
|
31
|
+
chosen = (@requested.empty? ? defaults : @requested).map { descend(it.delete_suffix("/")) }
|
|
32
|
+
vet(chosen)
|
|
33
|
+
survivors = distinct(chosen)
|
|
34
|
+
raise(Hashira::Error, "no Ruby files under #{survivors.join(", ")}") if bare?(survivors)
|
|
35
|
+
survivors
|
|
13
36
|
end
|
|
14
|
-
private_class_method :defaults
|
|
15
37
|
|
|
16
|
-
def
|
|
38
|
+
def bare?(survivors) = survivors.flat_map { Dir["#{it}/**/*.rb"] }.empty?
|
|
39
|
+
|
|
40
|
+
def defaults
|
|
41
|
+
return ["lib"] if Dir["lib/*/"].any? || Dir["lib/*.rb"].any?
|
|
42
|
+
raise(Hashira::Error, "no lib/ directory here — pass the source directory explicitly")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def descend(directory)
|
|
17
46
|
child = sole(directory)
|
|
18
47
|
return directory unless child && Dir["#{child}/*/"].any? && (Dir["#{directory}/*.rb"] - ["#{child}.rb"]).empty?
|
|
19
48
|
descend(child)
|
|
20
49
|
end
|
|
21
50
|
|
|
22
|
-
def
|
|
51
|
+
def sole(directory)
|
|
23
52
|
subdirectories = Dir["#{directory}/*/"]
|
|
24
53
|
subdirectories.size == 1 ? subdirectories.first.delete_suffix("/") : nil
|
|
25
54
|
end
|
|
26
|
-
private_class_method :descend, :sole
|
|
27
55
|
|
|
28
|
-
def
|
|
56
|
+
def vet(directories)
|
|
57
|
+
file = directories.find { File.file?(it) }
|
|
58
|
+
raise(Hashira::Error, "#{file} is a file — hashira takes directories (try: hashira #{File.dirname(file)})") if file
|
|
29
59
|
missing = directories.reject { Dir.exist?(it) }
|
|
30
60
|
raise(Hashira::Error, "no such directory: #{missing.join(", ")}") unless missing.empty?
|
|
31
|
-
@directories = directories.map { it.delete_suffix("/") }
|
|
32
|
-
@rails = @directories.any? { config?(File.expand_path(it)) }
|
|
33
61
|
end
|
|
34
62
|
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
def files = @directories.flat_map { Dir["#{it}/**/*.rb"] }.sort
|
|
40
|
-
|
|
41
|
-
def package(path)
|
|
42
|
-
first, rest = relative(path).delete_suffix(".rb").split("/", 2)
|
|
43
|
-
return ROOT_PACKAGE unless rest || folder?(path, first)
|
|
44
|
-
contested.include?(first) ? "#{parent(path)}/#{first}" : first
|
|
63
|
+
def distinct(directories)
|
|
64
|
+
pairs = directories.map { [it, File.realpath(it)] }.uniq(&:last)
|
|
65
|
+
pairs.filter_map { |dir, path| dir unless nested?(path, pairs) }
|
|
45
66
|
end
|
|
46
67
|
|
|
47
|
-
def
|
|
48
|
-
|
|
49
|
-
def label = @directories.join(", ")
|
|
50
|
-
|
|
51
|
-
def root = ROOT_PACKAGE
|
|
52
|
-
|
|
53
|
-
private
|
|
68
|
+
def nested?(path, pairs) = pairs.any? { path.start_with?("#{it.last}/") }
|
|
54
69
|
|
|
55
70
|
def config?(directory)
|
|
56
71
|
["config/application.rb", "../config/application.rb"].any? { File.exist?(File.expand_path(it, directory)) }
|
|
@@ -59,13 +74,13 @@ class Hashira::Project
|
|
|
59
74
|
def folder?(path, name) = Dir.exist?("#{parent(path)}/#{name}")
|
|
60
75
|
|
|
61
76
|
def contested
|
|
62
|
-
@
|
|
63
|
-
|
|
77
|
+
@_contested ||=
|
|
78
|
+
directories.flat_map { |directory| Dir["#{directory}/*/"].map { File.basename(it) } }
|
|
64
79
|
.tally.filter_map { |name, count| name if count > 1 }
|
|
65
80
|
end
|
|
66
81
|
|
|
67
82
|
def parent(path)
|
|
68
|
-
|
|
83
|
+
directories.find { path.start_with?("#{it}/") } ||
|
|
69
84
|
raise(Hashira::Error, "#{path} is outside the analyzed directories")
|
|
70
85
|
end
|
|
71
86
|
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Report::Columns
|
|
4
|
+
CAP = 48
|
|
5
|
+
HEAD = 23
|
|
6
|
+
GAP = " "
|
|
7
|
+
NUMBER = /\A-?\d+(?:\.\d+)?\z/
|
|
8
|
+
|
|
9
|
+
def initialize(headers, rows, io: $stdout)
|
|
10
|
+
@raw = [headers, *rows]
|
|
11
|
+
@io = io
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def print
|
|
15
|
+
@io.puts(header)
|
|
16
|
+
@io.puts(rule)
|
|
17
|
+
body
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def body = rows.each { @io.puts(line(it)) }
|
|
21
|
+
|
|
22
|
+
def header = line(headers)
|
|
23
|
+
|
|
24
|
+
def rule = "-" * [header, *rows.map { line(it) }].map(&:length).max
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def clipped = @_clipped ||= @raw.map { |cells| cells.map { clip(it) } }
|
|
29
|
+
|
|
30
|
+
def headers = clipped.first
|
|
31
|
+
|
|
32
|
+
def rows = clipped.drop(1)
|
|
33
|
+
|
|
34
|
+
def line(cells) = cells.map.with_index { |cell, column| pad(cell, column) }.join(GAP).rstrip
|
|
35
|
+
|
|
36
|
+
def pad(cell, column)
|
|
37
|
+
width = widths[column]
|
|
38
|
+
numeric[column] ? cell.rjust(width) : cell.ljust(width)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def widths = @_widths ||= headers.each_index.map { |column| down(column).map(&:length).max }
|
|
42
|
+
|
|
43
|
+
def numeric = @_numeric ||= headers.each_index.map { |column| rows.any? && counted?(column) }
|
|
44
|
+
|
|
45
|
+
def counted?(column) = rows.all? { NUMBER.match?(it[column]) }
|
|
46
|
+
|
|
47
|
+
def down(column) = [headers[column], *rows.map { it[column] }]
|
|
48
|
+
|
|
49
|
+
def clip(cell)
|
|
50
|
+
text = cell.to_s
|
|
51
|
+
return text if text.length <= CAP
|
|
52
|
+
"#{text[0, HEAD]}…#{text[(HEAD + 1 - CAP)..]}"
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -2,35 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
class Hashira::Report::ComplexityTable
|
|
4
4
|
TOP = 10
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
METHOD_HEADERS = %w[method Cog Calls Loc].freeze
|
|
6
|
+
CLASS_HEADERS = %w[class Cog Methods Peak].freeze
|
|
7
7
|
METHOD_TITLE = "Cognitive complexity — worst methods (Cog = how hard to read, Calls = message sends)"
|
|
8
8
|
CLASS_TITLE = "Per-class rollup (Cog total survives extract-method; Peak is the worst method it hides)"
|
|
9
9
|
|
|
10
|
-
def initialize(complexity, io: $stdout)
|
|
10
|
+
def initialize(complexity, top: TOP, io: $stdout)
|
|
11
11
|
@complexity = complexity
|
|
12
|
+
@top = top
|
|
12
13
|
@io = io
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def print
|
|
16
|
-
section(@complexity.ranked,
|
|
17
|
-
section(@complexity.classes,
|
|
17
|
+
section(@complexity.ranked, METHOD_HEADERS, METHOD_TITLE)
|
|
18
|
+
section(@complexity.classes, CLASS_HEADERS, CLASS_TITLE)
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
private
|
|
21
22
|
|
|
22
|
-
def section(scores,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@io.puts
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def heading(row_format, columns, title)
|
|
29
|
-
header = format(row_format, *columns)
|
|
23
|
+
def section(scores, headers, title)
|
|
24
|
+
rows = ranked(scores)
|
|
25
|
+
return if rows.empty?
|
|
30
26
|
@io.puts("#{title}:\n\n")
|
|
31
|
-
@io.
|
|
32
|
-
@io.puts
|
|
27
|
+
Hashira::Report::Columns.new(headers, rows.map(&:cells), io: @io).print
|
|
28
|
+
@io.puts
|
|
33
29
|
end
|
|
34
30
|
|
|
35
|
-
def ranked(scores) = scores.select { it.cognitive.positive? }.first(
|
|
31
|
+
def ranked(scores) = scores.select { it.cognitive.positive? }.first(@top)
|
|
36
32
|
end
|
|
@@ -17,7 +17,7 @@ class Hashira::Report::GraphPayload
|
|
|
17
17
|
|
|
18
18
|
def edges
|
|
19
19
|
@graph.weighted.map do |from, to, weight|
|
|
20
|
-
{ from:, to:, weight
|
|
20
|
+
{ from:, to:, weight: }.merge(refs: @graph.evidence(from, to).to_a.sort)
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
end
|
|
@@ -2,32 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
class Hashira::Report::HotspotTable
|
|
4
4
|
TOP = 10
|
|
5
|
-
|
|
5
|
+
HEADERS = %w[file Cog Dup Churn Rank].freeze
|
|
6
6
|
|
|
7
|
-
def initialize(hotspots, io: $stdout)
|
|
7
|
+
def initialize(hotspots, top: TOP, io: $stdout)
|
|
8
8
|
@hotspots = hotspots
|
|
9
|
+
@top = top
|
|
9
10
|
@io = io
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def print
|
|
13
14
|
return if ranked.empty?
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
@io.puts("Hotspots — cost × churn (where refactoring pays the most):\n\n")
|
|
16
|
+
Hashira::Report::Columns.new(HEADERS, ranked.map(&:cells), io: @io).print
|
|
16
17
|
legend
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
private
|
|
20
21
|
|
|
21
|
-
def ranked = @
|
|
22
|
-
|
|
23
|
-
def rows = ranked.each { @io.puts(format(ROW, *it.cells)) }
|
|
24
|
-
|
|
25
|
-
def heading
|
|
26
|
-
@io.puts("Hotspots — cost × churn (where refactoring pays the most):\n\n")
|
|
27
|
-
header = format(ROW, *%w[file Cog Dup Churn Rank])
|
|
28
|
-
@io.puts(header)
|
|
29
|
-
@io.puts("-" * header.length)
|
|
30
|
-
end
|
|
22
|
+
def ranked = @_ranked ||= @hotspots.files.first(@top)
|
|
31
23
|
|
|
32
24
|
def legend
|
|
33
25
|
@io.puts("\nLegend: Cog cognitive complexity, Dup mass of the clones the file carries,")
|
data/lib/hashira/report/json.rb
CHANGED
|
@@ -8,14 +8,22 @@ class Hashira::Report::Json
|
|
|
8
8
|
@io = io
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
SCHEMA = 1
|
|
12
|
+
|
|
11
13
|
def print
|
|
12
|
-
@io.puts(JSON.pretty_generate(payload))
|
|
14
|
+
@io.puts(@view.compact ? JSON.generate(payload) : JSON.pretty_generate(payload))
|
|
13
15
|
0
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
private
|
|
17
19
|
|
|
18
|
-
def payload = base.merge(coupling).merge(sections.compact)
|
|
20
|
+
def payload = about.merge(base).merge(coupling).merge(sections.compact)
|
|
21
|
+
|
|
22
|
+
def about
|
|
23
|
+
project = @view.project
|
|
24
|
+
{ version: SCHEMA, packaging: @view.graph&.packaging, targets: project.directories }
|
|
25
|
+
.merge(files: project.files.size)
|
|
26
|
+
end
|
|
19
27
|
|
|
20
28
|
def base = { findings: @view.findings.all.map { rendered(it) }, accepted: accepted }
|
|
21
29
|
|
|
@@ -1,47 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Report::MetricsTable
|
|
4
|
-
|
|
4
|
+
TOP = 25
|
|
5
|
+
|
|
6
|
+
def initialize(graph, top: TOP, io: $stdout)
|
|
5
7
|
@graph = graph
|
|
8
|
+
@top = top
|
|
6
9
|
@io = io
|
|
7
10
|
end
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
HEADERS = %w[package TC Ca Ce I Cyc].freeze
|
|
10
13
|
|
|
11
14
|
def print
|
|
12
|
-
|
|
13
|
-
kept.each { |package, metric| @io.puts(row(package, metric)) }
|
|
15
|
+
Hashira::Report::Columns.new(HEADERS, kept.map { |package, metric| row(package, metric) }, io: @io).print
|
|
14
16
|
note unless spared.empty?
|
|
17
|
+
more unless over.zero?
|
|
15
18
|
legend
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
private
|
|
19
22
|
|
|
20
|
-
def ranked = @graph.metrics.sort_by { |_package, metric| metric.
|
|
23
|
+
def ranked = @graph.metrics.sort_by { |_package, metric| metric.order }
|
|
21
24
|
|
|
22
|
-
def split = @
|
|
25
|
+
def split = @_split ||= (ranked.size > @top ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
|
|
23
26
|
|
|
24
|
-
def kept = split.first
|
|
27
|
+
def kept = split.first.first(@top)
|
|
25
28
|
|
|
26
29
|
def spared = split.last
|
|
27
30
|
|
|
31
|
+
def over = split.first.size - kept.size
|
|
32
|
+
|
|
28
33
|
def leaf?(metric) = metric.types <= 1 && metric.efferent.zero? && metric.afferent <= 1
|
|
29
34
|
|
|
30
|
-
def note
|
|
31
|
-
@io.puts(" + #{spared.size} single-type leaf packages (TC ≤ 1, Ca ≤ 1, Ce = 0) — hidden for brevity")
|
|
32
|
-
end
|
|
35
|
+
def note = @io.puts(" + #{leaves} (TC ≤ 1, Ca ≤ 1, Ce = 0) — hidden for brevity")
|
|
33
36
|
|
|
34
|
-
def
|
|
35
|
-
@io.puts(format("%-12s %3s %3s %3s %5s %-3s", *%w[package TC Ca Ce I Cyc]))
|
|
36
|
-
@io.puts("-" * 40)
|
|
37
|
-
end
|
|
37
|
+
def leaves = Hashira::Report::Phrases.count(spared.size, "single-type leaf package")
|
|
38
38
|
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
end
|
|
39
|
+
def more = @io.puts(" … and #{over} more — raise the cap with --top, or read them all with --json")
|
|
40
|
+
|
|
41
|
+
def row(package, metric) = [package, *metric.cells, cycle(package)]
|
|
42
|
+
|
|
43
|
+
def cycle(package) = @graph.cycles.through?(package) ? "YES" : "-"
|
|
45
44
|
|
|
46
45
|
def legend
|
|
47
46
|
@io.puts("\nLegend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Report::Notices
|
|
4
|
+
ISSUES = "https://github.com/giacope/hashira/issues"
|
|
5
|
+
|
|
6
|
+
def initialize(io: $stderr)
|
|
7
|
+
@io = io
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def scanning(files) = interactive { @io.puts("hashira: reading #{files} files…") }
|
|
11
|
+
|
|
12
|
+
def finished(files, seconds) = interactive { @io.puts("hashira: #{files} files in #{seconds}s") }
|
|
13
|
+
|
|
14
|
+
def rails
|
|
15
|
+
@io.puts("hashira: this looks like a Rails root — `hashira app` reads the application, not just lib/")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def churn(label)
|
|
19
|
+
@io.puts("hashira: no git history for #{label} — hotspots are ranked by cost alone")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def crashed(error)
|
|
23
|
+
@io.puts("hashira: internal error — #{error.class}: #{error.message}")
|
|
24
|
+
@io.puts(" at #{error.backtrace.first}")
|
|
25
|
+
@io.puts(" this is a bug in hashira — please report it at #{ISSUES}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unparsed(count, sample)
|
|
29
|
+
@io.puts("hashira: #{count} of the files did not parse — #{sample}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def interactive
|
|
35
|
+
yield if @io.tty?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Hashira::Report::Phrases
|
|
4
|
+
FLATTEN = "flatten the branching — guard clauses, early returns, or polymorphism."
|
|
5
|
+
|
|
6
|
+
LOOP_BODY = "extract the loop body into its own method."
|
|
7
|
+
|
|
4
8
|
COMPLEXITY_ADVICE = {
|
|
5
|
-
"if" =>
|
|
6
|
-
"elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch."
|
|
7
|
-
|
|
9
|
+
"if" => FLATTEN, "else" => FLATTEN,
|
|
10
|
+
"elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch."
|
|
11
|
+
}.merge(
|
|
8
12
|
"case" => "a case this size often wants polymorphism or a dispatch table.",
|
|
9
13
|
"boolean" => "name the compound condition in a predicate method.",
|
|
10
|
-
"rescue" => "narrow the rescue, or lift error handling to the caller."
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
+
"rescue" => "narrow the rescue, or lift error handling to the caller."
|
|
15
|
+
).merge(
|
|
16
|
+
"while" => LOOP_BODY, "until" => LOOP_BODY, "for" => LOOP_BODY
|
|
17
|
+
).merge(
|
|
14
18
|
"unless" => "invert to a guard clause or a named predicate.",
|
|
15
19
|
"ternary" => "extract the nested ternary into a named method."
|
|
16
|
-
|
|
20
|
+
).freeze
|
|
17
21
|
|
|
18
22
|
DUPLICATION_ADVICE = {
|
|
19
23
|
identical: "byte-for-byte identical — extract a shared method and call it from each site.",
|
|
20
24
|
literal: "differs only in literal values — extract a method, pass them as arguments.",
|
|
21
|
-
message: "differs only in the receiver or message — extract a method taking the receiver."
|
|
25
|
+
message: "differs only in the receiver or message — extract a method taking the receiver."
|
|
26
|
+
}.merge(
|
|
22
27
|
constant: "differs only in a constant — extract a method and parameterize it.",
|
|
23
28
|
structure: "the control flow differs — extract the common core, but verify by hand (lower confidence).",
|
|
24
29
|
mixed: "extract the shared shape and pass what differs as parameters."
|
|
25
|
-
|
|
30
|
+
).freeze
|
|
26
31
|
|
|
27
32
|
module_function
|
|
28
33
|
|
|
@@ -38,9 +43,9 @@ module Hashira::Report::Phrases
|
|
|
38
43
|
|
|
39
44
|
def on_sdp_violation(finding)
|
|
40
45
|
detail = finding.detail
|
|
41
|
-
from, to = detail.
|
|
42
|
-
"#{from} (I=#{score(detail
|
|
43
|
-
"(I=#{score(detail
|
|
46
|
+
from, to = detail.deconstruct
|
|
47
|
+
"#{from} (I=#{score(detail.from_instability)}) depends on the LESS stable #{to} " \
|
|
48
|
+
"(I=#{score(detail.to_instability)}) — churn in #{to} will force churn in #{from}. " \
|
|
44
49
|
"Invert the edge or extract the stable part of #{to} that #{from} needs."
|
|
45
50
|
end
|
|
46
51
|
|
|
@@ -69,18 +74,20 @@ module Hashira::Report::Phrases
|
|
|
69
74
|
|
|
70
75
|
def on_complexity(finding)
|
|
71
76
|
detail = finding.detail
|
|
72
|
-
"#{finding.package} — cognitive #{detail
|
|
73
|
-
"(#{detail
|
|
77
|
+
"#{finding.package} — cognitive #{detail.cognitive}, #{detail.calls} calls " \
|
|
78
|
+
"(#{detail.site}). #{COMPLEXITY_ADVICE.fetch(detail.dominant)}"
|
|
74
79
|
end
|
|
75
80
|
|
|
76
81
|
def on_duplication(finding)
|
|
77
82
|
detail = finding.detail
|
|
78
|
-
"#{detail
|
|
79
|
-
"#{DUPLICATION_ADVICE.fetch(detail
|
|
83
|
+
"#{detail.size} similar fragments (mass #{detail.mass}) — " \
|
|
84
|
+
"#{DUPLICATION_ADVICE.fetch(detail.kind)}#{footnote(detail)}"
|
|
80
85
|
end
|
|
81
86
|
|
|
82
87
|
def score(value) = format("%.2f", value)
|
|
83
88
|
|
|
89
|
+
def count(number, noun) = "#{number} #{number == 1 ? noun : "#{noun}s"}"
|
|
90
|
+
|
|
84
91
|
def clause(part) = "#{part[:users].join(", ")} #{verb(part)} #{part[:constants].join(", ")}"
|
|
85
92
|
|
|
86
93
|
def verb(part)
|
|
@@ -93,6 +100,6 @@ module Hashira::Report::Phrases
|
|
|
93
100
|
end
|
|
94
101
|
|
|
95
102
|
def footnote(detail)
|
|
96
|
-
detail
|
|
103
|
+
detail.hot ? " Both sites change often — fix one, miss the other." : ""
|
|
97
104
|
end
|
|
98
105
|
end
|
data/lib/hashira/report/text.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Report::Text
|
|
4
|
+
FINDINGS = 25
|
|
5
|
+
|
|
4
6
|
def initialize(view, io: $stdout)
|
|
5
7
|
@view = view
|
|
6
8
|
@io = io
|
|
@@ -19,31 +21,37 @@ class Hashira::Report::Text
|
|
|
19
21
|
def coupling
|
|
20
22
|
graph = @view.graph
|
|
21
23
|
header(graph)
|
|
22
|
-
Hashira::Report::MetricsTable
|
|
24
|
+
table(Hashira::Report::MetricsTable, graph)
|
|
23
25
|
Hashira::Report::DependencyMap.new(graph, io: @io).print
|
|
24
26
|
folded(graph.folds)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def folded(folds)
|
|
28
30
|
return if folds.empty?
|
|
29
|
-
@io.puts("
|
|
31
|
+
@io.puts("Folded (single-type classes joined to their base or domain):")
|
|
30
32
|
folds.each { @io.puts(" #{it[:from]} -> #{it[:to]} (#{it[:via]})") }
|
|
33
|
+
@io.puts
|
|
31
34
|
end
|
|
32
35
|
|
|
33
|
-
def
|
|
36
|
+
def table(kind, subject) = kind.new(subject, top: @view.top || kind::TOP, io: @io).print
|
|
37
|
+
|
|
38
|
+
def complexity = table(Hashira::Report::ComplexityTable, @view.complexity)
|
|
34
39
|
|
|
35
|
-
def hotspots = Hashira::Report::HotspotTable
|
|
40
|
+
def hotspots = table(Hashira::Report::HotspotTable, @view.hotspots)
|
|
36
41
|
|
|
37
42
|
def header(graph)
|
|
38
43
|
packages = graph.packages.size
|
|
39
|
-
@io.puts(banner(packages))
|
|
44
|
+
@io.puts(banner(graph.packaging, packages))
|
|
40
45
|
caveat if packages == 1
|
|
41
46
|
end
|
|
42
47
|
|
|
43
|
-
def banner(packages)
|
|
44
|
-
"Package (
|
|
48
|
+
def banner(grouping, packages)
|
|
49
|
+
"Package (#{grouping}) metrics for #{@view.project.label} " \
|
|
50
|
+
"(#{count(packages, "package")}, #{count(total, "file")})\n\n"
|
|
45
51
|
end
|
|
46
52
|
|
|
53
|
+
def count(number, noun) = Hashira::Report::Phrases.count(number, noun)
|
|
54
|
+
|
|
47
55
|
def total = @view.project.files.size
|
|
48
56
|
|
|
49
57
|
def caveat
|
|
@@ -55,7 +63,7 @@ class Hashira::Report::Text
|
|
|
55
63
|
|
|
56
64
|
def findings
|
|
57
65
|
all = @view.findings.all
|
|
58
|
-
@io.puts("
|
|
66
|
+
@io.puts("Findings (#{all.size}):")
|
|
59
67
|
list(all)
|
|
60
68
|
accepted
|
|
61
69
|
@io.puts("\n Full evidence + machine format: hashira --json") unless all.empty?
|
|
@@ -63,7 +71,14 @@ class Hashira::Report::Text
|
|
|
63
71
|
|
|
64
72
|
def list(all)
|
|
65
73
|
return @io.puts(" none ✓ — structure is healthy") if all.empty?
|
|
66
|
-
|
|
74
|
+
shown = all.first(@view.top || FINDINGS)
|
|
75
|
+
shown.each { Hashira::Report::FindingLines.new(it, indent: " ", io: @io).emit }
|
|
76
|
+
elided(all.size - shown.size)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def elided(rest)
|
|
80
|
+
return if rest.zero?
|
|
81
|
+
@io.puts(" … and #{rest} more — raise the cap with --top, or read them all with --json")
|
|
67
82
|
end
|
|
68
83
|
|
|
69
84
|
def accepted
|
data/lib/hashira/report/view.rb
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Hashira
|
|
4
4
|
module Report
|
|
5
|
-
View =
|
|
5
|
+
View =
|
|
6
|
+
Data.define(:project, :graph, :complexity, :duplication, :hotspots, :findings, :top, :compact) do
|
|
7
|
+
def initialize(top: nil, compact: nil, **) = super
|
|
8
|
+
end
|
|
6
9
|
end
|
|
7
10
|
end
|
|
@@ -6,10 +6,9 @@ class Hashira::Smells::Census
|
|
|
6
6
|
def initialize(project, trees)
|
|
7
7
|
@project = project
|
|
8
8
|
@trees = trees
|
|
9
|
-
@ownership = Hashira::Smells::Ownership.new(trees.values)
|
|
10
9
|
end
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
def ownership = @_ownership ||= Hashira::Smells::Ownership.new(@trees.values)
|
|
13
12
|
|
|
14
13
|
def types = @trees.flat_map { |path, tree| harvest(@project.relative(path), tree) }
|
|
15
14
|
|
|
@@ -28,8 +27,8 @@ class Hashira::Smells::Census
|
|
|
28
27
|
def kind(node) = node.is_a?(Prism::ModuleNode) ? :module : :class
|
|
29
28
|
|
|
30
29
|
def defs(name, node, file)
|
|
31
|
-
Hashira::Smells::Visibility.new(node).entries.map do |
|
|
32
|
-
Hashira::Smells::MethodContext.new(owner: name, node:
|
|
30
|
+
Hashira::Smells::Visibility.new(node).entries.map do |definition, section|
|
|
31
|
+
Hashira::Smells::MethodContext.new(owner: name, node: definition, file:, section:, ownership:)
|
|
33
32
|
end
|
|
34
33
|
end
|
|
35
34
|
end
|
data/lib/hashira/smells/check.rb
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Smells::Check
|
|
4
|
-
def self.kind = name.split("::").last.gsub(/(?<=[a-z])(?=[A-Z])/, "_").downcase
|
|
5
|
-
|
|
6
|
-
def self.judge? = false
|
|
7
|
-
|
|
8
4
|
def initialize(subject)
|
|
9
5
|
@subject = subject
|
|
10
6
|
end
|
|
@@ -18,7 +14,7 @@ class Hashira::Smells::Check
|
|
|
18
14
|
|
|
19
15
|
attr_reader :subject
|
|
20
16
|
|
|
21
|
-
def kind = self.class.
|
|
17
|
+
def kind = Hashira::Smells::Kind.new(self.class).to_s
|
|
22
18
|
|
|
23
19
|
def label = subject.subject
|
|
24
20
|
|
|
@@ -9,12 +9,12 @@ module Hashira
|
|
|
9
9
|
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
|
-
def names(
|
|
12
|
+
def names(definition) = parts(definition).flat_map { expand(it) }
|
|
13
13
|
|
|
14
|
-
def arguments(
|
|
14
|
+
def arguments(definition) = parts(definition).grep_v(Prism::BlockParameterNode).flat_map { expand(it) }
|
|
15
15
|
|
|
16
|
-
def parts(
|
|
17
|
-
node =
|
|
16
|
+
def parts(definition)
|
|
17
|
+
node = definition.parameters
|
|
18
18
|
return [] unless node
|
|
19
19
|
node.requireds + node.optionals + node.posts + node.keywords +
|
|
20
20
|
[node.rest, node.keyword_rest, node.block].compact
|
|
@@ -5,7 +5,7 @@ class Hashira::Smells::ControlParameter < Hashira::Smells::Check
|
|
|
5
5
|
|
|
6
6
|
def smelly? = culprits.any?
|
|
7
7
|
|
|
8
|
-
def culprits = @
|
|
8
|
+
def culprits = @_culprits ||= subject.parameters.filter_map { |name| culprit(name) }
|
|
9
9
|
|
|
10
10
|
def culprit(name)
|
|
11
11
|
lines = spots(name).uniq
|