hashira 0.4.0 → 0.5.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 +58 -0
- data/README.md +86 -25
- data/lib/hashira/analysis/finding.rb +2 -2
- data/lib/hashira/ci/ratchet.rb +5 -9
- data/lib/hashira/cli/fail_on.rb +15 -5
- data/lib/hashira/cli/run.rb +8 -4
- data/lib/hashira/cli/usage.rb +8 -5
- data/lib/hashira/complexity/boolean_run.rb +5 -3
- data/lib/hashira/complexity/cognitive_score.rb +21 -14
- data/lib/hashira/complexity/if_chain.rb +14 -14
- data/lib/hashira/complexity/method_finding.rb +3 -20
- data/lib/hashira/complexity/rescue_scan.rb +9 -9
- data/lib/hashira/complexity/{analyzer.rb → scores.rb} +6 -5
- data/lib/hashira/coupling/audiences.rb +49 -0
- data/lib/hashira/{analysis → coupling}/catalog.rb +3 -3
- data/lib/hashira/{analysis → coupling}/census.rb +17 -8
- data/lib/hashira/{analysis → coupling}/constant_registry.rb +6 -2
- data/lib/hashira/coupling/cycle_findings.rb +31 -0
- data/lib/hashira/{analysis → coupling}/cycle_search.rb +1 -1
- data/lib/hashira/{analysis → coupling}/cycles.rb +2 -2
- data/lib/hashira/{analysis → coupling}/definition.rb +1 -1
- data/lib/hashira/{analysis → coupling}/definitions.rb +1 -1
- data/lib/hashira/{analysis → coupling}/edge.rb +1 -1
- data/lib/hashira/{analysis → coupling}/edge_map.rb +10 -10
- data/lib/hashira/{analysis → coupling}/folder_placement.rb +2 -2
- data/lib/hashira/{analysis → coupling}/folding.rb +4 -2
- data/lib/hashira/{analysis → coupling}/graph.rb +18 -8
- data/lib/hashira/{analysis → coupling}/metric.rb +1 -1
- data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
- data/lib/hashira/{analysis → coupling}/namespace_placement.rb +3 -3
- data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +1 -1
- data/lib/hashira/{analysis → coupling}/naming.rb +2 -2
- data/lib/hashira/{analysis → coupling}/no_folding.rb +1 -1
- data/lib/hashira/{analysis → coupling}/placement.rb +3 -3
- data/lib/hashira/coupling/references.rb +55 -0
- data/lib/hashira/coupling/report.rb +28 -0
- data/lib/hashira/coupling/roll_call.rb +45 -0
- data/lib/hashira/coupling/roll_call_findings.rb +30 -0
- data/lib/hashira/{analysis → coupling}/roster.rb +2 -2
- data/lib/hashira/{analysis → coupling}/rule.rb +1 -1
- data/lib/hashira/{analysis/resolver.rb → coupling/scope.rb} +2 -2
- data/lib/hashira/{analysis → coupling}/sdp_check.rb +1 -1
- data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
- data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
- data/lib/hashira/coupling/words.rb +29 -0
- data/lib/hashira/diagram/{renderer.rb → source.rb} +2 -2
- data/lib/hashira/duplication/{analyzer.rb → clones.rb} +3 -3
- data/lib/hashira/duplication/{clusterer.rb → clusters.rb} +3 -3
- data/lib/hashira/duplication/delta.rb +6 -14
- data/lib/hashira/duplication/duplication_finding.rb +6 -10
- data/lib/hashira/duplication/{extractor.rb → harvest.rb} +1 -1
- data/lib/hashira/duplication/sequence.rb +3 -1
- data/lib/hashira/duplication/similarity.rb +5 -1
- data/lib/hashira/duplication/variance.rb +3 -3
- data/lib/hashira/hotspots/rollup.rb +1 -1
- data/lib/hashira/pipeline.rb +15 -10
- data/lib/hashira/report/complexity_table.rb +1 -1
- data/lib/hashira/report/finding_lines.rb +1 -1
- data/lib/hashira/report/json.rb +5 -3
- data/lib/hashira/report/phrases.rb +98 -0
- data/lib/hashira/report/smell_phrases.rb +68 -0
- data/lib/hashira/smells/census.rb +32 -0
- data/lib/hashira/smells/check.rb +30 -0
- data/lib/hashira/smells/conditions.rb +44 -0
- data/lib/hashira/smells/contexts.rb +64 -0
- data/lib/hashira/smells/control_parameter.rb +22 -0
- data/lib/hashira/smells/data_clump.rb +30 -0
- data/lib/hashira/smells/duplicate_method_call.rb +54 -0
- data/lib/hashira/smells/feature_envy.rb +17 -0
- data/lib/hashira/smells/instance_variable_assumption.rb +44 -0
- data/lib/hashira/smells/manual_dispatch.rb +16 -0
- data/lib/hashira/smells/module_initialize.rb +9 -0
- data/lib/hashira/smells/nil_check.rb +32 -0
- data/lib/hashira/smells/param_check.rb +51 -0
- data/lib/hashira/smells/refs.rb +53 -0
- data/lib/hashira/smells/repeated_conditional.rb +29 -0
- data/lib/hashira/smells/report.rb +31 -0
- data/lib/hashira/smells/scope.rb +29 -0
- data/lib/hashira/smells/too_many_instance_variables.rb +24 -0
- data/lib/hashira/smells/utility_function.rb +13 -0
- data/lib/hashira/smells/visibility.rb +64 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +65 -30
- metadata +60 -31
- data/lib/hashira/analysis/cycle_findings.rb +0 -38
- data/lib/hashira/analysis/references.rb +0 -40
- data/lib/hashira/analysis/sdp_violation_findings.rb +0 -29
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rule"
|
|
4
|
+
|
|
5
|
+
class Hashira::Coupling::RollCallFindings < Hashira::Coupling::Rule
|
|
6
|
+
KIND = "roll_call"
|
|
7
|
+
|
|
8
|
+
def list = rolls.map { entry(it) }
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def rolls = Hashira::Coupling::RollCall.new(lists, homes).rolls
|
|
13
|
+
|
|
14
|
+
def sightings
|
|
15
|
+
@sightings ||=
|
|
16
|
+
graph.trees.filter_map do |file, tree|
|
|
17
|
+
words = Hashira::Coupling::Words.list(tree)
|
|
18
|
+
[project.relative(file), graph.charge(file), words] unless words.empty?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def lists = sightings.to_h { |file, _home, words| [file, words] }
|
|
23
|
+
|
|
24
|
+
def homes = sightings.to_h { |file, home, _words| [file, home] }
|
|
25
|
+
|
|
26
|
+
def entry(roll)
|
|
27
|
+
words, files, packages = roll.deconstruct
|
|
28
|
+
finding(package: packages.first, digest: words.join(","), evidence: files, detail: { words:, files:, packages: })
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::
|
|
3
|
+
class Hashira::Coupling::Roster
|
|
4
4
|
def initialize(placed)
|
|
5
|
-
@registry = Hashira::
|
|
5
|
+
@registry = Hashira::Coupling::ConstantRegistry.new
|
|
6
6
|
@types = Hash.new(0)
|
|
7
7
|
counted = Set.new
|
|
8
8
|
placed.each { |definition, package| admit(definition, package, counted) }
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::
|
|
3
|
+
class Hashira::Coupling::Scope
|
|
4
4
|
def initialize(registry, catalog, placement)
|
|
5
5
|
@registry = registry
|
|
6
6
|
@catalog = catalog
|
|
@@ -24,5 +24,5 @@ class Hashira::Analysis::Resolver
|
|
|
24
24
|
segments.length.downto(1).filter_map { @registry.exact(@catalog.strip(entry + segments.first(it))) }.first
|
|
25
25
|
end
|
|
26
26
|
|
|
27
|
-
def qualify(found) = (found unless found == Hashira::
|
|
27
|
+
def qualify(found) = (found unless found == Hashira::Coupling::ConstantRegistry::AMBIGUOUS)
|
|
28
28
|
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rule"
|
|
4
|
+
|
|
5
|
+
class Hashira::Coupling::SdpViolationFindings < Hashira::Coupling::Rule
|
|
6
|
+
KIND = "sdp_violation"
|
|
7
|
+
|
|
8
|
+
def list
|
|
9
|
+
ranked.map { |from, to| violation(from, to) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def ranked = graph.violations.sort_by { |from, to| instability(from) - instability(to) }
|
|
15
|
+
|
|
16
|
+
def violation(from, to)
|
|
17
|
+
finding(package: from, evidence: graph.evidence(from, to).to_a.first(5), detail: detail(from, to))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def detail(from, to)
|
|
21
|
+
{ from:, to:, from_instability: instability(from), to_instability: instability(to) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def instability(package) = metrics[package].instability
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rule"
|
|
4
|
+
|
|
5
|
+
class Hashira::Coupling::WideEdgeFindings < Hashira::Coupling::Rule
|
|
6
|
+
KIND = "wide_edge"
|
|
7
|
+
|
|
8
|
+
WIDTH = 5
|
|
9
|
+
|
|
10
|
+
def list
|
|
11
|
+
graph.edges.select { wide?(it) }.map { entry(it) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def wide?(edge) = graph.constants(edge).size >= WIDTH
|
|
17
|
+
|
|
18
|
+
def entry(edge)
|
|
19
|
+
from, to = edge.deconstruct
|
|
20
|
+
finding(
|
|
21
|
+
package: from, digest: "#{from} -> #{to}", evidence: graph.evidence(from, to).to_a.first(4),
|
|
22
|
+
detail: { from:, to:, constants: graph.constants(edge) }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira::Coupling::Words
|
|
6
|
+
PATTERN = /\A[a-z][a-z_]*\z/
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def list(tree)
|
|
11
|
+
Hashira::Analysis::NodeWalk.collect(tree).flat_map { entries(it) }.compact.grep(PATTERN).to_set
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def entries(node)
|
|
15
|
+
case node
|
|
16
|
+
when Prism::ArrayNode then node.elements.map { word(it) }
|
|
17
|
+
when Prism::HashNode then keys(node)
|
|
18
|
+
else []
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def keys(node) = node.elements.grep(Prism::AssocNode).map { word(it.key) }
|
|
23
|
+
|
|
24
|
+
def word(node)
|
|
25
|
+
case node
|
|
26
|
+
when Prism::SymbolNode, Prism::StringNode then node.unescaped
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::Diagram::
|
|
3
|
+
class Hashira::Diagram::Source
|
|
4
4
|
def initialize(graph, format, io: $stdout)
|
|
5
5
|
@graph = graph
|
|
6
6
|
@format = format
|
|
7
7
|
@io = io
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def print
|
|
11
11
|
edges = @graph.weighted
|
|
12
12
|
@io.puts((@format == :dot ? Hashira::Diagram::Dot.new(edges) : Hashira::Diagram::Mermaid.new(edges)).source)
|
|
13
13
|
0
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::Duplication::
|
|
3
|
+
class Hashira::Duplication::Clones
|
|
4
4
|
def initialize(project, trees, churn)
|
|
5
5
|
@project = project
|
|
6
6
|
@trees = trees
|
|
7
7
|
@churn = churn
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def clusters = @clusters ||= Hashira::Duplication::
|
|
10
|
+
def clusters = @clusters ||= Hashira::Duplication::Clusters.new(fragments).sorted
|
|
11
11
|
|
|
12
12
|
def findings = clusters.map { |cluster| Hashira::Duplication::DuplicationFinding.new(cluster, @churn).to_finding }
|
|
13
13
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
|
-
def fragments = Hashira::Duplication::
|
|
16
|
+
def fragments = Hashira::Duplication::Harvest.new(@project, @trees).fragments
|
|
17
17
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::Duplication::
|
|
3
|
+
class Hashira::Duplication::Clusters
|
|
4
4
|
PREFILTER = 12
|
|
5
5
|
BASE_MASS = 16
|
|
6
6
|
NEAR_MASS = 40
|
|
@@ -12,10 +12,10 @@ class Hashira::Duplication::Clusterer
|
|
|
12
12
|
@sets = Hashira::Duplication::UnionFind.new
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def
|
|
15
|
+
def sorted
|
|
16
16
|
@fragments.group_by(&:types).each_value { |group| chain(group) }
|
|
17
17
|
Hashira::Duplication::NearMiss.new(@fragments).pairs.each { |left, right| @sets.union(left, right) }
|
|
18
|
-
Hashira::Duplication::Maximal.new(sized).reduced
|
|
18
|
+
Hashira::Duplication::Maximal.new(sized).reduced.sort_by { -it.mass }
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
private
|
|
@@ -1,26 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Duplication::Delta
|
|
4
|
-
ADVICE = {
|
|
5
|
-
identical: "byte-for-byte identical — extract a shared method and call it from each site.",
|
|
6
|
-
literal: "differs only in literal values — extract a method, pass them as arguments.",
|
|
7
|
-
message: "differs only in the receiver or message — extract a method taking the receiver.",
|
|
8
|
-
constant: "differs only in a constant — extract a method and parameterize it.",
|
|
9
|
-
structure: "the control flow differs — extract the common core, but verify by hand (lower confidence).",
|
|
10
|
-
mixed: "extract the shared shape and pass what differs as parameters."
|
|
11
|
-
}.freeze
|
|
12
|
-
|
|
13
4
|
def initialize(cluster)
|
|
14
5
|
@cluster = cluster
|
|
15
6
|
end
|
|
16
7
|
|
|
17
|
-
def summary = ADVICE.fetch(kind)
|
|
18
|
-
|
|
19
8
|
def kind
|
|
20
9
|
tags = kinds
|
|
21
|
-
|
|
22
|
-
return :structure if tags.include?(:structure)
|
|
23
|
-
tags.size == 1 ? tags.first : :mixed
|
|
10
|
+
tags.empty? ? :identical : label(tags)
|
|
24
11
|
end
|
|
25
12
|
|
|
26
13
|
def to_h
|
|
@@ -32,6 +19,11 @@ class Hashira::Duplication::Delta
|
|
|
32
19
|
|
|
33
20
|
private
|
|
34
21
|
|
|
22
|
+
def label(tags)
|
|
23
|
+
return :structure if tags.include?(:structure)
|
|
24
|
+
tags.size == 1 ? tags.first : :mixed
|
|
25
|
+
end
|
|
26
|
+
|
|
35
27
|
def kinds
|
|
36
28
|
@cluster.others.flat_map { |other| Hashira::Duplication::Variance.new(@cluster.canonical, other).kinds }.uniq
|
|
37
29
|
end
|
|
@@ -8,21 +8,17 @@ class Hashira::Duplication::DuplicationFinding
|
|
|
8
8
|
|
|
9
9
|
def to_finding
|
|
10
10
|
site = @cluster.canonical
|
|
11
|
-
Hashira::Analysis::Finding.new(
|
|
12
|
-
kind: "duplication", package: site.location, digest: site.digest,
|
|
13
|
-
cycle: nil, message:, evidence:
|
|
14
|
-
)
|
|
11
|
+
Hashira::Analysis::Finding.new(kind: "duplication", package: site.location, digest: site.digest, detail:, evidence:)
|
|
15
12
|
end
|
|
16
13
|
|
|
17
14
|
private
|
|
18
15
|
|
|
19
|
-
def
|
|
20
|
-
|
|
16
|
+
def detail
|
|
17
|
+
{
|
|
18
|
+
size: @cluster.size, mass: @cluster.mass,
|
|
19
|
+
kind: Hashira::Duplication::Delta.new(@cluster).kind, hot: @churn.hot?(@cluster.sites)
|
|
20
|
+
}
|
|
21
21
|
end
|
|
22
22
|
|
|
23
23
|
def evidence = @cluster.sites.sort_by(&:rank).map(&:range)
|
|
24
|
-
|
|
25
|
-
def note
|
|
26
|
-
@churn.hot?(@cluster.sites) ? " Both sites change often — fix one, miss the other." : ""
|
|
27
|
-
end
|
|
28
24
|
end
|
|
@@ -26,7 +26,9 @@ class Hashira::Duplication::Sequence
|
|
|
26
26
|
|
|
27
27
|
def lengths(segment) = MIN_STATEMENTS..[segment.size, MAX_STATEMENTS].min
|
|
28
28
|
|
|
29
|
-
def slide(segment, length) = (
|
|
29
|
+
def slide(segment, length) = spans(segment, length).map { fragment(segment[it, length]) }
|
|
30
|
+
|
|
31
|
+
def spans(segment, length) = 0..(segment.size - length)
|
|
30
32
|
|
|
31
33
|
def fragment(roots) = Hashira::Duplication::Fragment.new(@file, roots)
|
|
32
34
|
end
|
|
@@ -38,6 +38,10 @@ class Hashira::Duplication::Similarity
|
|
|
38
38
|
end
|
|
39
39
|
|
|
40
40
|
def cell(prev, row, token, index)
|
|
41
|
-
|
|
41
|
+
match?(token, index) ? prev[index] + 1 : carry(prev, row, index)
|
|
42
42
|
end
|
|
43
|
+
|
|
44
|
+
def match?(token, index) = @right[index] == token
|
|
45
|
+
|
|
46
|
+
def carry(prev, row, index) = [prev[index + 1], row[index]].max
|
|
43
47
|
end
|
|
@@ -40,10 +40,10 @@ class Hashira::Duplication::Variance
|
|
|
40
40
|
end
|
|
41
41
|
|
|
42
42
|
def signature(node)
|
|
43
|
-
type
|
|
44
|
-
return literal(node) if LITERALS.include?(type)
|
|
45
|
-
node.name if NAMED.include?(type)
|
|
43
|
+
LITERALS.include?(node.type) ? literal(node) : label(node)
|
|
46
44
|
end
|
|
47
45
|
|
|
46
|
+
def label(node) = (node.name if NAMED.include?(node.type))
|
|
47
|
+
|
|
48
48
|
def literal(node) = VALUED.include?(node.type) ? node.value : node.unescaped
|
|
49
49
|
end
|
data/lib/hashira/pipeline.rb
CHANGED
|
@@ -1,27 +1,34 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
|
+
require_relative "coupling/report"
|
|
4
5
|
|
|
5
6
|
class Hashira::Pipeline
|
|
6
|
-
ANALYZERS = %i[coupling complexity duplication].freeze
|
|
7
|
+
ANALYZERS = %i[coupling complexity duplication smells].freeze
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
STRUCTURAL = Hashira::Coupling::Report::RULES.map { it::KIND }.freeze
|
|
9
10
|
|
|
10
11
|
def initialize(project, enabled: ANALYZERS, packaging: :auto)
|
|
11
12
|
@project = project
|
|
12
13
|
@enabled = enabled
|
|
13
14
|
@trees = project.files.to_h { [it, parse(it)] }
|
|
14
|
-
@
|
|
15
|
+
@coupling = Hashira::Coupling::Report.new(project, @trees, packaging: settle(packaging))
|
|
15
16
|
end
|
|
16
17
|
|
|
17
|
-
attr_reader :project
|
|
18
|
+
attr_reader :project
|
|
19
|
+
|
|
20
|
+
def graph = @coupling.graph
|
|
18
21
|
|
|
19
22
|
def complexity
|
|
20
|
-
@complexity ||= Hashira::Complexity::
|
|
23
|
+
@complexity ||= Hashira::Complexity::Scores.new(@project, @trees) if enabled?(:complexity)
|
|
21
24
|
end
|
|
22
25
|
|
|
23
26
|
def duplication
|
|
24
|
-
@duplication ||= Hashira::Duplication::
|
|
27
|
+
@duplication ||= Hashira::Duplication::Clones.new(@project, @trees, churn) if enabled?(:duplication)
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def smells
|
|
31
|
+
@smells ||= Hashira::Smells::Report.new(@project, @trees) if enabled?(:smells)
|
|
25
32
|
end
|
|
26
33
|
|
|
27
34
|
def hotspots
|
|
@@ -32,18 +39,16 @@ class Hashira::Pipeline
|
|
|
32
39
|
|
|
33
40
|
def enabled?(analyzer) = @enabled.include?(analyzer)
|
|
34
41
|
|
|
35
|
-
def findings = structural + listed(complexity) + listed(duplication)
|
|
42
|
+
def findings = structural + listed(complexity) + listed(duplication) + listed(smells)
|
|
36
43
|
|
|
37
44
|
private
|
|
38
45
|
|
|
39
|
-
def census(packaging) = Hashira::Analysis::Census.new(@project, @trees, packaging:)
|
|
40
|
-
|
|
41
46
|
def settle(packaging)
|
|
42
47
|
return packaging unless packaging == :auto
|
|
43
48
|
@project.rails? ? :namespace : :folder
|
|
44
49
|
end
|
|
45
50
|
|
|
46
|
-
def structural = enabled?(:coupling) ?
|
|
51
|
+
def structural = enabled?(:coupling) ? @coupling.findings : []
|
|
47
52
|
|
|
48
53
|
def listed(analyzer) = analyzer ? analyzer.findings : []
|
|
49
54
|
|
|
@@ -13,7 +13,7 @@ class Hashira::Report::ComplexityTable
|
|
|
13
13
|
end
|
|
14
14
|
|
|
15
15
|
def print
|
|
16
|
-
section(@complexity.
|
|
16
|
+
section(@complexity.ranked, METHOD_ROW, %w[method Cog Calls Loc], METHOD_TITLE)
|
|
17
17
|
section(@complexity.classes, CLASS_ROW, %w[class Cog Methods Peak], CLASS_TITLE)
|
|
18
18
|
end
|
|
19
19
|
|
|
@@ -10,7 +10,7 @@ class Hashira::Report::FindingLines
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def print
|
|
13
|
-
@io.puts("#{@indent}#{@finding.kind}: #{@finding
|
|
13
|
+
@io.puts("#{@indent}#{@finding.kind}: #{Hashira::Report::Phrases.message(@finding)}")
|
|
14
14
|
@finding.evidence.first(SAMPLE).each { @io.puts("#{@indent} · #{it}") }
|
|
15
15
|
end
|
|
16
16
|
|
data/lib/hashira/report/json.rb
CHANGED
|
@@ -17,7 +17,9 @@ class Hashira::Report::Json
|
|
|
17
17
|
|
|
18
18
|
def payload = base.merge(coupling).merge(sections.compact)
|
|
19
19
|
|
|
20
|
-
def base = { findings: @view.findings.all.map(
|
|
20
|
+
def base = { findings: @view.findings.all.map { rendered(it) }, accepted: accepted }
|
|
21
|
+
|
|
22
|
+
def rendered(finding) = finding.to_h.merge(message: Hashira::Report::Phrases.message(finding))
|
|
21
23
|
|
|
22
24
|
def coupling
|
|
23
25
|
graph = @view.graph
|
|
@@ -33,12 +35,12 @@ class Hashira::Report::Json
|
|
|
33
35
|
end
|
|
34
36
|
|
|
35
37
|
def accepted
|
|
36
|
-
@view.findings.accepted.map { |finding, reason| finding.
|
|
38
|
+
@view.findings.accepted.map { |finding, reason| rendered(finding).merge(reason:) }
|
|
37
39
|
end
|
|
38
40
|
|
|
39
41
|
def complexity = { methods: the_methods, classes: the_classes }
|
|
40
42
|
|
|
41
|
-
def the_methods = @view.complexity.
|
|
43
|
+
def the_methods = @view.complexity.ranked.map(&:to_h)
|
|
42
44
|
|
|
43
45
|
def the_classes = @view.complexity.classes.map(&:to_h)
|
|
44
46
|
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira::Report::Phrases
|
|
4
|
+
COMPLEXITY_ADVICE = {
|
|
5
|
+
"if" => "flatten the branching — guard clauses, early returns, or polymorphism.",
|
|
6
|
+
"elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch.",
|
|
7
|
+
"else" => "flatten the branching — guard clauses, early returns, or polymorphism.",
|
|
8
|
+
"case" => "a case this size often wants polymorphism or a dispatch table.",
|
|
9
|
+
"boolean" => "name the compound condition in a predicate method.",
|
|
10
|
+
"rescue" => "narrow the rescue, or lift error handling to the caller.",
|
|
11
|
+
"while" => "extract the loop body into its own method.",
|
|
12
|
+
"until" => "extract the loop body into its own method.",
|
|
13
|
+
"for" => "extract the loop body into its own method.",
|
|
14
|
+
"unless" => "invert to a guard clause or a named predicate.",
|
|
15
|
+
"ternary" => "extract the nested ternary into a named method."
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
DUPLICATION_ADVICE = {
|
|
19
|
+
identical: "byte-for-byte identical — extract a shared method and call it from each site.",
|
|
20
|
+
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.",
|
|
22
|
+
constant: "differs only in a constant — extract a method and parameterize it.",
|
|
23
|
+
structure: "the control flow differs — extract the common core, but verify by hand (lower confidence).",
|
|
24
|
+
mixed: "extract the shared shape and pass what differs as parameters."
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def message(finding) = public_send("on_#{finding.kind}", finding)
|
|
30
|
+
|
|
31
|
+
def on_cycle(finding)
|
|
32
|
+
detail = finding.detail
|
|
33
|
+
from, to = detail[:weak]
|
|
34
|
+
weight = detail[:weight]
|
|
35
|
+
"#{finding.package} can reach itself: #{finding.cycle.join(" -> ")} — any change may ripple back " \
|
|
36
|
+
"around. The lightest edge on this cycle is #{from} -> #{to} (#{weight} ref#{"s" unless weight == 1})."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_sdp_violation(finding)
|
|
40
|
+
detail = finding.detail
|
|
41
|
+
from, to = detail.values_at(:from, :to)
|
|
42
|
+
"#{from} (I=#{score(detail[:from_instability])}) depends on the LESS stable #{to} " \
|
|
43
|
+
"(I=#{score(detail[:to_instability])}) — churn in #{to} will force churn in #{from}. " \
|
|
44
|
+
"Invert the edge or extract the stable part of #{to} that #{from} needs."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def on_mixed_audience(finding)
|
|
48
|
+
package = finding.package
|
|
49
|
+
parts = finding.detail[:parts]
|
|
50
|
+
"#{package} splits #{parts.size} ways: #{parts.map { clause(it) }.join("; ")} — " \
|
|
51
|
+
"parts with separate client bases are separate packages in disguise. " \
|
|
52
|
+
"Split #{package} along that seam#{addendum(parts)}."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def on_wide_edge(finding)
|
|
56
|
+
detail = finding.detail
|
|
57
|
+
from, to = detail.values_at(:from, :to)
|
|
58
|
+
names = detail[:constants]
|
|
59
|
+
"#{from} -> #{to} is #{names.size} constants wide (#{names.join(", ")}) — " \
|
|
60
|
+
"every one is a reason for #{from} to change. Front #{to} with one facade."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def on_roll_call(finding)
|
|
64
|
+
detail = finding.detail
|
|
65
|
+
"the words #{detail[:words].join(", ")} are listed together in #{detail[:files].join(", ")} — " \
|
|
66
|
+
"#{detail[:packages].size} packages keep one roll-call in sync by hand. " \
|
|
67
|
+
"Make the list data with a single owner."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def on_complexity(finding)
|
|
71
|
+
detail = finding.detail
|
|
72
|
+
"#{finding.package} — cognitive #{detail[:cognitive]}, #{detail[:calls]} calls " \
|
|
73
|
+
"(#{detail[:site]}). #{COMPLEXITY_ADVICE.fetch(detail[:dominant])}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def on_duplication(finding)
|
|
77
|
+
detail = finding.detail
|
|
78
|
+
"#{detail[:size]} similar fragments (mass #{detail[:mass]}) — " \
|
|
79
|
+
"#{DUPLICATION_ADVICE.fetch(detail[:kind])}#{footnote(detail)}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def score(value) = format("%.2f", value)
|
|
83
|
+
|
|
84
|
+
def clause(part) = "#{part[:users].join(", ")} #{verb(part)} #{part[:constants].join(", ")}"
|
|
85
|
+
|
|
86
|
+
def verb(part)
|
|
87
|
+
return "share" if part[:shared]
|
|
88
|
+
part[:users].size == 1 ? "alone uses" : "use"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def addendum(parts)
|
|
92
|
+
parts.any? { it[:shared] } ? ", keeping the shared constants as the base layer the rest builds on" : ""
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def footnote(detail)
|
|
96
|
+
detail[:hot] ? " Both sites change often — fix one, miss the other." : ""
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira::Report::Phrases
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def on_control_parameter(finding)
|
|
7
|
+
detail = finding.detail
|
|
8
|
+
"#{finding.package} is steered by #{quoted(detail[:names])} (#{detail[:site]}). " \
|
|
9
|
+
"Split the method, or pass a strategy instead of a flag."
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def on_data_clump(finding)
|
|
13
|
+
"#{finding.package} passes the same parameters between methods (#{finding.detail[:site]}). " \
|
|
14
|
+
"Introduce a parameter object."
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def on_duplicate_method_call(finding)
|
|
18
|
+
"#{finding.package} repeats identical calls (#{finding.detail[:site]}). " \
|
|
19
|
+
"Name the result in a local variable."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def on_feature_envy(finding)
|
|
23
|
+
detail = finding.detail
|
|
24
|
+
names = detail[:names]
|
|
25
|
+
"#{finding.package} refers to #{quoted(names)} more than to self (#{detail[:site]}). " \
|
|
26
|
+
"The behavior may belong on #{names.first}."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def on_instance_variable_assumption(finding)
|
|
30
|
+
"#{finding.package} reads instance variables never set in initialize (#{finding.detail[:site]}). " \
|
|
31
|
+
"Assign them in initialize, or pass the data explicitly."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def on_manual_dispatch(finding)
|
|
35
|
+
"#{finding.package} dispatches manually via respond_to? (#{finding.detail[:site]}). " \
|
|
36
|
+
"Trust the duck type, or split the callers into two adapters."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_module_initialize(finding)
|
|
40
|
+
"#{finding.package} defines initialize in a module (#{finding.detail[:site]}). " \
|
|
41
|
+
"Move construction into the including class."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def on_nil_check(finding)
|
|
45
|
+
"#{finding.package} checks for nil (#{finding.detail[:site]}). " \
|
|
46
|
+
"Prefer a default, a null object, or polymorphism."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def on_repeated_conditional(finding)
|
|
50
|
+
tally(finding, "branches on the same test %d times", "Replace the scattered checks with polymorphism.")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def on_too_many_instance_variables(finding)
|
|
54
|
+
tally(finding, "holds %d instance variables", "Split the class, or gather related fields into value objects.")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def on_utility_function(finding)
|
|
58
|
+
"#{finding.package} touches no instance state (#{finding.detail[:site]}). " \
|
|
59
|
+
"Move it onto the object it serves, or make it a module function."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def tally(finding, event, advice)
|
|
63
|
+
detail = finding.detail
|
|
64
|
+
"#{finding.package} #{format(event, detail[:count])} (#{detail[:site]}). #{advice}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def quoted(names) = names.map { "'#{it}'" }.join(", ")
|
|
68
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::Census
|
|
6
|
+
def initialize(project, trees)
|
|
7
|
+
@project = project
|
|
8
|
+
@trees = trees
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def types = @trees.flat_map { |path, tree| harvest(@project.relative(path), tree) }
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def harvest(file, tree)
|
|
16
|
+
found = []
|
|
17
|
+
Hashira::Analysis::TypeWalk.each(tree) { |node, full| found << context(file, node, full.join("::")) }
|
|
18
|
+
found
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def context(file, node, name)
|
|
22
|
+
Hashira::Smells::TypeContext.new(name:, node:, kind: kind(node), file:, defs: defs(name, node, file))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def kind(node) = node.is_a?(Prism::ModuleNode) ? :module : :class
|
|
26
|
+
|
|
27
|
+
def defs(name, node, file)
|
|
28
|
+
Hashira::Smells::Visibility.new(node).entries.map do |def_node, section|
|
|
29
|
+
Hashira::Smells::MethodContext.new(owner: name, node: def_node, file:, section:)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|