hashira 0.7.0 → 0.9.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 +94 -0
- data/README.md +57 -3
- data/exe/hashira +1 -1
- data/lib/hashira/analysis/finding.rb +11 -1
- data/lib/hashira/analysis/syntax.rb +6 -6
- data/lib/hashira/analysis/type_walk.rb +4 -0
- data/lib/hashira/churn.rb +1 -10
- data/lib/hashira/ci/accepted.rb +9 -7
- data/lib/hashira/ci/baseline.rb +53 -33
- data/lib/hashira/ci/comparison.rb +33 -0
- data/lib/hashira/ci/diff.rb +0 -15
- data/lib/hashira/ci/gate.rb +3 -3
- data/lib/hashira/ci/mark.rb +7 -0
- data/lib/hashira/ci/ratchet.rb +12 -4
- data/lib/hashira/ci/ratchet_report.rb +4 -4
- data/lib/hashira/ci/scope.rb +0 -1
- data/lib/hashira/ci/slice.rb +21 -0
- data/lib/hashira/ci/sweep.rb +13 -0
- data/lib/hashira/cli/arguments.rb +12 -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 +6 -6
- data/lib/hashira/cli/flag.rb +3 -1
- data/lib/hashira/cli/flags.rb +10 -0
- data/lib/hashira/cli/format.rb +1 -1
- data/lib/hashira/cli/needs.rb +13 -2
- data/lib/hashira/cli/only.rb +16 -0
- data/lib/hashira/cli/options.rb +8 -10
- data/lib/hashira/cli/package_by.rb +1 -1
- data/lib/hashira/cli/run.rb +9 -8
- data/lib/hashira/cli.rb +22 -21
- 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 -7
- 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 +3 -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/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 +20 -2
- data/lib/hashira/duplication/harvest.rb +7 -5
- data/lib/hashira/duplication/index.rb +3 -2
- data/lib/hashira/duplication/literal.rb +25 -0
- data/lib/hashira/duplication/similarity.rb +2 -2
- data/lib/hashira/duplication/variance.rb +7 -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 +22 -14
- data/lib/hashira/project.rb +35 -34
- data/lib/hashira/report/columns.rb +14 -8
- data/lib/hashira/report/graph_payload.rb +1 -1
- data/lib/hashira/report/hotspot_table.rb +1 -1
- data/lib/hashira/report/json.rb +2 -1
- data/lib/hashira/report/metrics_table.rb +3 -3
- data/lib/hashira/report/phrases.rb +23 -18
- data/lib/hashira/report/smell_phrases.rb +2 -2
- data/lib/hashira/smells/branches.rb +62 -0
- data/lib/hashira/smells/census.rb +11 -6
- data/lib/hashira/smells/check.rb +1 -5
- data/lib/hashira/smells/contexts.rb +7 -5
- 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 +25 -5
- 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 +5 -15
- data/lib/hashira/smells/kind.rb +9 -0
- data/lib/hashira/smells/lineage.rb +92 -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 +8 -4
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +12 -0
- metadata +13 -1
|
@@ -7,19 +7,21 @@ class Hashira::Duplication::Harvest
|
|
|
7
7
|
|
|
8
8
|
def initialize(project, trees)
|
|
9
9
|
@project = project
|
|
10
|
-
@
|
|
10
|
+
@trees = trees
|
|
11
11
|
end
|
|
12
12
|
|
|
13
|
-
|
|
13
|
+
def fragments = @_fragments ||= @trees.flat_map { |path, tree| scan(@project.relative(path), tree) }
|
|
14
14
|
|
|
15
15
|
private
|
|
16
16
|
|
|
17
|
-
def scan(
|
|
17
|
+
def scan(relative, tree)
|
|
18
18
|
nodes = Hashira::Analysis::NodeWalk.collect(tree)
|
|
19
|
-
windows(
|
|
19
|
+
windows(relative, nodes) + wholes(nodes).map { Hashira::Duplication::Fragment.new(relative, [it]) }
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
-
def windows(
|
|
22
|
+
def windows(relative, nodes)
|
|
23
|
+
runs(nodes).flat_map { Hashira::Duplication::Sequence.new(relative, it).fragments }
|
|
24
|
+
end
|
|
23
25
|
|
|
24
26
|
def runs(nodes) = nodes.filter_map { it.body if it.is_a?(Prism::StatementsNode) }
|
|
25
27
|
|
|
@@ -6,13 +6,14 @@ class Hashira::Duplication::Index
|
|
|
6
6
|
|
|
7
7
|
def initialize(fragments)
|
|
8
8
|
@fragments = fragments
|
|
9
|
-
@frequency = frequencies(fragments)
|
|
10
9
|
end
|
|
11
10
|
|
|
12
11
|
def buckets = grouped.values.select { |bucket| bucket.size.between?(2, MAX_BUCKET) }
|
|
13
12
|
|
|
14
13
|
private
|
|
15
14
|
|
|
15
|
+
def frequency = @_frequency ||= frequencies(@fragments)
|
|
16
|
+
|
|
16
17
|
def frequencies(fragments)
|
|
17
18
|
fragments.each_with_object(Hash.new(0)) { |fragment, counts| tally(counts, fragment) }
|
|
18
19
|
end
|
|
@@ -27,5 +28,5 @@ class Hashira::Duplication::Index
|
|
|
27
28
|
|
|
28
29
|
def file(index, fragment) = rarest(fragment).each { |type| index[type] << fragment }
|
|
29
30
|
|
|
30
|
-
def rarest(fragment) = fragment.types.uniq.min_by(RARE) {
|
|
31
|
+
def rarest(fragment) = fragment.types.uniq.min_by(RARE) { frequency[it] }
|
|
31
32
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Duplication::Literal
|
|
6
|
+
TYPES = [
|
|
7
|
+
Prism::ArrayNode, Prism::AssocNode, Prism::FalseNode, Prism::FloatNode,
|
|
8
|
+
Prism::HashNode, Prism::IntegerNode, Prism::KeywordHashNode, Prism::NilNode,
|
|
9
|
+
Prism::StringNode, Prism::SymbolNode, Prism::TrueNode
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
def initialize(node)
|
|
13
|
+
@node = node
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def literal? = TYPES.include?(@node.class) && parts.all?(&:literal?)
|
|
17
|
+
|
|
18
|
+
def literals? = parts.all?(&:literal?)
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def parts = children.map { Hashira::Duplication::Literal.new(it) }
|
|
23
|
+
|
|
24
|
+
def children = Array(@node&.compact_child_nodes)
|
|
25
|
+
end
|
|
@@ -8,7 +8,7 @@ class Hashira::Duplication::Similarity
|
|
|
8
8
|
|
|
9
9
|
def ratio
|
|
10
10
|
return 0.0 if @left.empty? || @right.empty?
|
|
11
|
-
normalized(
|
|
11
|
+
normalized(subsequence)
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def meets?(threshold) = ceiling >= threshold && ratio >= threshold
|
|
@@ -29,7 +29,7 @@ class Hashira::Duplication::Similarity
|
|
|
29
29
|
true
|
|
30
30
|
end
|
|
31
31
|
|
|
32
|
-
def
|
|
32
|
+
def subsequence = @left.reduce(blank) { |prev, token| advance(prev, token) }.last
|
|
33
33
|
|
|
34
34
|
def blank = Array.new(@right.size + 1, 0)
|
|
35
35
|
|
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
3
5
|
class Hashira::Duplication::Variance
|
|
4
6
|
LITERALS = %i[integer_node float_node string_node symbol_node].freeze
|
|
5
7
|
VALUED = %i[integer_node float_node].freeze
|
|
@@ -27,7 +29,11 @@ class Hashira::Duplication::Variance
|
|
|
27
29
|
|
|
28
30
|
def pairs = @canonical.nodes.zip(@other.nodes)
|
|
29
31
|
|
|
30
|
-
def named = @
|
|
32
|
+
def named = @_named ||= pairs.select { |left, _| NAMED.include?(left.type) && !inner?(left) }
|
|
33
|
+
|
|
34
|
+
def inner?(node) = nested.any? { it.equal?(node) }
|
|
35
|
+
|
|
36
|
+
def nested = @_nested ||= @canonical.nodes.grep(Prism::ConstantPathNode).filter_map(&:parent)
|
|
31
37
|
|
|
32
38
|
def differing = pairs.select { |pair| varies?(*pair) }.map(&:first)
|
|
33
39
|
|
data/lib/hashira/error.rb
CHANGED
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Focus
|
|
4
|
+
SITE = /\A(?<path>[^\s:]+\.rb):/
|
|
5
|
+
|
|
6
|
+
def initialize(project, paths)
|
|
7
|
+
@project = project
|
|
8
|
+
@paths = paths
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def narrowing? = !@paths.empty?
|
|
12
|
+
|
|
13
|
+
def narrow(findings)
|
|
14
|
+
return findings unless narrowing?
|
|
15
|
+
findings.select { named?(it) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def wanted = @_wanted ||= @paths.select { covered?(it) }.map { @project.relative(it) }
|
|
21
|
+
|
|
22
|
+
def covered?(path) = @project.directories.any? { path.start_with?("#{it}/") }
|
|
23
|
+
|
|
24
|
+
def named?(finding) = sites(finding).any? { wanted.include?(it) }
|
|
25
|
+
|
|
26
|
+
def sites(finding) = quotes(finding).filter_map { SITE.match(it)&.[](:path) }
|
|
27
|
+
|
|
28
|
+
def quotes(finding) = [finding.package, finding.detail.to_h[:site], *finding.evidence].compact
|
|
29
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::GitLog
|
|
4
|
+
LOG = %w[log --no-renames --name-only --format=].freeze
|
|
5
|
+
|
|
6
|
+
def initialize(directory)
|
|
7
|
+
@directory = directory
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def counts = output.split("\n").map(&:strip).reject(&:empty?).tally
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def output
|
|
15
|
+
IO.popen(["git", "-C", @directory, *LOG], err: File::NULL, &:read)
|
|
16
|
+
rescue SystemCallError
|
|
17
|
+
""
|
|
18
|
+
end
|
|
19
|
+
end
|
|
@@ -23,9 +23,9 @@ class Hashira::Hotspots::Rollup
|
|
|
23
23
|
|
|
24
24
|
def clusters = @duplication ? @duplication.clusters : []
|
|
25
25
|
|
|
26
|
-
def cognitive = @
|
|
26
|
+
def cognitive = @_cognitive ||= bucketed(scores.map { [it.file, it.cognitive] })
|
|
27
27
|
|
|
28
|
-
def duplicated = @
|
|
28
|
+
def duplicated = @_duplicated ||= bucketed(clusters.flat_map(&:masses))
|
|
29
29
|
|
|
30
30
|
def bucketed(charges) = charges.each_with_object(Hash.new(0)) { |(file, cost), total| total[file] += cost }
|
|
31
31
|
end
|
data/lib/hashira/pipeline.rb
CHANGED
|
@@ -10,50 +10,58 @@ class Hashira::Pipeline
|
|
|
10
10
|
STRUCTURAL = Hashira::Coupling::Report::RULES.map { it::KIND }.freeze
|
|
11
11
|
|
|
12
12
|
SMELLS = (
|
|
13
|
-
Hashira::Smells::Report::CHECKS.map(
|
|
13
|
+
Hashira::Smells::Report::CHECKS.map { Hashira::Smells::Kind.new(it).to_s } + [Hashira::Smells::BoundarySprawl::KIND]
|
|
14
14
|
).sort.freeze
|
|
15
15
|
|
|
16
|
-
def initialize(project, enabled: ANALYZERS, packaging: :auto)
|
|
16
|
+
def initialize(project, enabled: ANALYZERS, packaging: :auto, only: [])
|
|
17
17
|
@project = project
|
|
18
18
|
@enabled = enabled
|
|
19
|
-
@
|
|
20
|
-
@
|
|
19
|
+
@packaging = packaging
|
|
20
|
+
@only = only
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
attr_reader :project
|
|
24
24
|
|
|
25
|
-
def unparsed =
|
|
25
|
+
def unparsed = parsed.unparsed
|
|
26
26
|
|
|
27
|
-
def graph =
|
|
27
|
+
def graph = coupling.graph
|
|
28
28
|
|
|
29
|
-
def complexity = @
|
|
29
|
+
def complexity = @_complexity ||= analyzed(:complexity, Hashira::Complexity::Scores)
|
|
30
30
|
|
|
31
|
-
def duplication = @
|
|
31
|
+
def duplication = @_duplication ||= analyzed(:duplication, Hashira::Duplication::Clones, churn)
|
|
32
32
|
|
|
33
|
-
def smells = @
|
|
33
|
+
def smells = @_smells ||= analyzed(:smells, Hashira::Smells::Report)
|
|
34
34
|
|
|
35
35
|
def hotspots
|
|
36
|
-
@
|
|
36
|
+
@_hotspots ||= Hashira::Hotspots::Rollup.new(complexity, duplication, churn) if complexity || duplication
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
-
def churn = @
|
|
39
|
+
def churn = @_churn ||= Hashira::Churn.build(@project.directories.first)
|
|
40
40
|
|
|
41
41
|
def enabled?(analyzer) = @enabled.include?(analyzer)
|
|
42
42
|
|
|
43
43
|
def analyzed(name, kind, *extra)
|
|
44
|
-
kind.new(@project,
|
|
44
|
+
kind.new(@project, parsed.all, *extra) if enabled?(name)
|
|
45
45
|
end
|
|
46
46
|
|
|
47
|
-
def findings = structural + listed(complexity) + listed(duplication) + listed(smells)
|
|
47
|
+
def findings = focus.narrow(structural + listed(complexity) + listed(duplication) + listed(smells))
|
|
48
|
+
|
|
49
|
+
def focus = @_focus ||= Hashira::Focus.new(@project, @only)
|
|
48
50
|
|
|
49
51
|
private
|
|
50
52
|
|
|
53
|
+
def parsed = @_parsed ||= Hashira::Trees.new(@project)
|
|
54
|
+
|
|
55
|
+
def coupling
|
|
56
|
+
@_coupling ||= Hashira::Coupling::Report.new(@project, parsed.all, packaging: settle(@packaging))
|
|
57
|
+
end
|
|
58
|
+
|
|
51
59
|
def settle(packaging)
|
|
52
60
|
return packaging unless packaging == :auto
|
|
53
61
|
@project.rails? ? :namespace : :folder
|
|
54
62
|
end
|
|
55
63
|
|
|
56
|
-
def structural = enabled?(:coupling) ?
|
|
64
|
+
def structural = enabled?(:coupling) ? coupling.findings : []
|
|
57
65
|
|
|
58
66
|
def listed(analyzer) = analyzer ? analyzer.findings : []
|
|
59
67
|
end
|
data/lib/hashira/project.rb
CHANGED
|
@@ -3,40 +3,15 @@
|
|
|
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
|
-
return ["lib"] if Dir["lib/*/"].any? || Dir["lib/*.rb"].any?
|
|
12
|
-
raise(Hashira::Error, "no lib/ directory here — pass the source directory explicitly")
|
|
13
|
-
end
|
|
14
|
-
private_class_method :defaults
|
|
15
|
-
|
|
16
|
-
def self.descend(directory)
|
|
17
|
-
child = sole(directory)
|
|
18
|
-
return directory unless child && Dir["#{child}/*/"].any? && (Dir["#{directory}/*.rb"] - ["#{child}.rb"]).empty?
|
|
19
|
-
descend(child)
|
|
20
|
-
end
|
|
10
|
+
def directories = @_directories ||= resolved
|
|
21
11
|
|
|
22
|
-
def
|
|
23
|
-
subdirectories = Dir["#{directory}/*/"]
|
|
24
|
-
subdirectories.size == 1 ? subdirectories.first.delete_suffix("/") : nil
|
|
25
|
-
end
|
|
26
|
-
private_class_method :descend, :sole
|
|
27
|
-
|
|
28
|
-
def initialize(directories)
|
|
29
|
-
vet(directories)
|
|
30
|
-
@directories = distinct(directories.map { it.delete_suffix("/") })
|
|
31
|
-
raise(Hashira::Error, "no Ruby files under #{label}") if files.empty?
|
|
32
|
-
@rails = @directories.any? { config?(File.expand_path(it)) }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
attr_reader :directories
|
|
12
|
+
def rails? = directories.any? { config?(File.expand_path(it)) }
|
|
36
13
|
|
|
37
|
-
def
|
|
38
|
-
|
|
39
|
-
def files = @directories.flat_map { Dir["#{it}/**/*.rb"] }.sort
|
|
14
|
+
def files = directories.flat_map { Dir["#{it}/**/*.rb"] }.sort
|
|
40
15
|
|
|
41
16
|
def package(path)
|
|
42
17
|
first, rest = relative(path).delete_suffix(".rb").split("/", 2)
|
|
@@ -46,12 +21,38 @@ class Hashira::Project
|
|
|
46
21
|
|
|
47
22
|
def relative(path) = path.delete_prefix("#{parent(path)}/")
|
|
48
23
|
|
|
49
|
-
def label =
|
|
24
|
+
def label = directories.join(", ")
|
|
50
25
|
|
|
51
26
|
def root = ROOT_PACKAGE
|
|
52
27
|
|
|
53
28
|
private
|
|
54
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
|
|
36
|
+
end
|
|
37
|
+
|
|
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)
|
|
46
|
+
child = sole(directory)
|
|
47
|
+
return directory unless child && Dir["#{child}/*/"].any? && (Dir["#{directory}/*.rb"] - ["#{child}.rb"]).empty?
|
|
48
|
+
descend(child)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def sole(directory)
|
|
52
|
+
subdirectories = Dir["#{directory}/*/"]
|
|
53
|
+
subdirectories.size == 1 ? subdirectories.first.delete_suffix("/") : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
55
56
|
def vet(directories)
|
|
56
57
|
file = directories.find { File.file?(it) }
|
|
57
58
|
raise(Hashira::Error, "#{file} is a file — hashira takes directories (try: hashira #{File.dirname(file)})") if file
|
|
@@ -73,13 +74,13 @@ class Hashira::Project
|
|
|
73
74
|
def folder?(path, name) = Dir.exist?("#{parent(path)}/#{name}")
|
|
74
75
|
|
|
75
76
|
def contested
|
|
76
|
-
@
|
|
77
|
-
|
|
77
|
+
@_contested ||=
|
|
78
|
+
directories.flat_map { |directory| Dir["#{directory}/*/"].map { File.basename(it) } }
|
|
78
79
|
.tally.filter_map { |name, count| name if count > 1 }
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
def parent(path)
|
|
82
|
-
|
|
83
|
+
directories.find { path.start_with?("#{it}/") } ||
|
|
83
84
|
raise(Hashira::Error, "#{path} is outside the analyzed directories")
|
|
84
85
|
end
|
|
85
86
|
end
|
|
@@ -7,7 +7,7 @@ class Hashira::Report::Columns
|
|
|
7
7
|
NUMBER = /\A-?\d+(?:\.\d+)?\z/
|
|
8
8
|
|
|
9
9
|
def initialize(headers, rows, io: $stdout)
|
|
10
|
-
@
|
|
10
|
+
@raw = [headers, *rows]
|
|
11
11
|
@io = io
|
|
12
12
|
end
|
|
13
13
|
|
|
@@ -17,14 +17,20 @@ class Hashira::Report::Columns
|
|
|
17
17
|
body
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def body =
|
|
20
|
+
def body = rows.each { @io.puts(line(it)) }
|
|
21
21
|
|
|
22
|
-
def header = line(
|
|
22
|
+
def header = line(headers)
|
|
23
23
|
|
|
24
|
-
def rule = "-" * [header,
|
|
24
|
+
def rule = "-" * [header, *rows.map { line(it) }].map(&:length).max
|
|
25
25
|
|
|
26
26
|
private
|
|
27
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
|
+
|
|
28
34
|
def line(cells) = cells.map.with_index { |cell, column| pad(cell, column) }.join(GAP).rstrip
|
|
29
35
|
|
|
30
36
|
def pad(cell, column)
|
|
@@ -32,13 +38,13 @@ class Hashira::Report::Columns
|
|
|
32
38
|
numeric[column] ? cell.rjust(width) : cell.ljust(width)
|
|
33
39
|
end
|
|
34
40
|
|
|
35
|
-
def widths = @
|
|
41
|
+
def widths = @_widths ||= headers.each_index.map { |column| down(column).map(&:length).max }
|
|
36
42
|
|
|
37
|
-
def numeric = @
|
|
43
|
+
def numeric = @_numeric ||= headers.each_index.map { |column| rows.any? && counted?(column) }
|
|
38
44
|
|
|
39
|
-
def counted?(column) =
|
|
45
|
+
def counted?(column) = rows.all? { NUMBER.match?(it[column]) }
|
|
40
46
|
|
|
41
|
-
def down(column) = [
|
|
47
|
+
def down(column) = [headers[column], *rows.map { it[column] }]
|
|
42
48
|
|
|
43
49
|
def clip(cell)
|
|
44
50
|
text = cell.to_s
|
|
@@ -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
|
|
@@ -19,7 +19,7 @@ class Hashira::Report::HotspotTable
|
|
|
19
19
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
|
-
def ranked = @
|
|
22
|
+
def ranked = @_ranked ||= @hotspots.files.first(@top)
|
|
23
23
|
|
|
24
24
|
def legend
|
|
25
25
|
@io.puts("\nLegend: Cog cognitive complexity, Dup mass of the clones the file carries,")
|
data/lib/hashira/report/json.rb
CHANGED
|
@@ -21,7 +21,8 @@ class Hashira::Report::Json
|
|
|
21
21
|
|
|
22
22
|
def about
|
|
23
23
|
project = @view.project
|
|
24
|
-
{ version: SCHEMA, packaging: @view.graph&.packaging, targets: project.directories
|
|
24
|
+
{ version: SCHEMA, packaging: @view.graph&.packaging, targets: project.directories }
|
|
25
|
+
.merge(files: project.files.size)
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def base = { findings: @view.findings.all.map { rendered(it) }, accepted: accepted }
|
|
@@ -22,7 +22,7 @@ class Hashira::Report::MetricsTable
|
|
|
22
22
|
|
|
23
23
|
def ranked = @graph.metrics.sort_by { |_package, metric| metric.order }
|
|
24
24
|
|
|
25
|
-
def split = @
|
|
25
|
+
def split = @_split ||= (ranked.size > @top ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
|
|
26
26
|
|
|
27
27
|
def kept = split.first.first(@top)
|
|
28
28
|
|
|
@@ -38,9 +38,9 @@ class Hashira::Report::MetricsTable
|
|
|
38
38
|
|
|
39
39
|
def more = @io.puts(" … and #{over} more — raise the cap with --top, or read them all with --json")
|
|
40
40
|
|
|
41
|
-
def row(package, metric) = [package, *metric.cells,
|
|
41
|
+
def row(package, metric) = [package, *metric.cells, cycle(package)]
|
|
42
42
|
|
|
43
|
-
def
|
|
43
|
+
def cycle(package) = @graph.cycles.through?(package) ? "YES" : "-"
|
|
44
44
|
|
|
45
45
|
def legend
|
|
46
46
|
@io.puts("\nLegend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),")
|
|
@@ -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,14 +74,14 @@ 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)
|
|
@@ -95,6 +100,6 @@ module Hashira::Report::Phrases
|
|
|
95
100
|
end
|
|
96
101
|
|
|
97
102
|
def footnote(detail)
|
|
98
|
-
detail
|
|
103
|
+
detail.hot ? " Both sites change often — fix one, miss the other." : ""
|
|
99
104
|
end
|
|
100
105
|
end
|
|
@@ -33,8 +33,8 @@ module Hashira::Report::Phrases
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def on_instance_variable_assumption(finding)
|
|
36
|
-
"#{finding.package} reads instance variables
|
|
37
|
-
"Assign them
|
|
36
|
+
"#{finding.package} reads instance variables nothing in the class assigns (#{finding.detail[:site]}). " \
|
|
37
|
+
"Assign them where the object is built, or pass the data explicitly."
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def on_manual_dispatch(finding)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::Branches
|
|
6
|
+
def initialize(root)
|
|
7
|
+
@root = root
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def together?(nodes) = nodes.combination(2).any? { |left, right| meet?(trail(left), trail(right)) }
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def meet?(left, right) = shared(left, right).none? { parted?(*it) }
|
|
15
|
+
|
|
16
|
+
def shared(left, right) = left.zip(right).select(&:last)
|
|
17
|
+
|
|
18
|
+
def parted?(here, there) = here.first.equal?(there.first) && here.last != there.last
|
|
19
|
+
|
|
20
|
+
def trail(node) = trails.fetch(node)
|
|
21
|
+
|
|
22
|
+
def trails
|
|
23
|
+
return @_trails if @_trails
|
|
24
|
+
@_trails = {}.compare_by_identity
|
|
25
|
+
chart(@root, [])
|
|
26
|
+
@_trails
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def chart(node, trail)
|
|
30
|
+
@_trails[node] = trail
|
|
31
|
+
node.compact_child_nodes.each { chart(it, trail + taken(node, it)) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def taken(node, child)
|
|
35
|
+
return arm_for(node, child) if node.is_a?(Prism::BeginNode)
|
|
36
|
+
arm = arms(node).index { it.equal?(child) }
|
|
37
|
+
arm ? [[node, arm]] : []
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def arm_for(node, child)
|
|
41
|
+
rescued = node.rescue_clause
|
|
42
|
+
return [] unless [node.statements, rescued, node.else_clause].any? { it.equal?(child) }
|
|
43
|
+
[[node, rescued.equal?(child) ? 1 : 0]]
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def arms(node) = (clauses(node) || conditions(node)).compact
|
|
47
|
+
|
|
48
|
+
def clauses(node)
|
|
49
|
+
case node
|
|
50
|
+
when Prism::IfNode, Prism::RescueNode then [node.statements, node.subsequent]
|
|
51
|
+
when Prism::UnlessNode then [node.statements, node.else_clause]
|
|
52
|
+
when Prism::RescueModifierNode then [node.expression, node.rescue_expression]
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
|
|
56
|
+
def conditions(node)
|
|
57
|
+
case node
|
|
58
|
+
when Prism::CaseNode, Prism::CaseMatchNode then node.conditions + [node.else_clause]
|
|
59
|
+
else []
|
|
60
|
+
end
|
|
61
|
+
end
|
|
62
|
+
end
|