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
|
+
class Hashira::Smells::Check
|
|
4
|
+
def initialize(subject)
|
|
5
|
+
@subject = subject
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def finding
|
|
9
|
+
return unless smelly?
|
|
10
|
+
Hashira::Analysis::Finding.new(kind:, package: label, detail:, evidence:)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
attr_reader :subject
|
|
16
|
+
|
|
17
|
+
def kind = self.class.name.split("::").last.gsub(/(?<=[a-z])(?=[A-Z])/, "_").downcase
|
|
18
|
+
|
|
19
|
+
def label = subject.subject
|
|
20
|
+
|
|
21
|
+
def site = subject.site
|
|
22
|
+
|
|
23
|
+
def detail = { site: }
|
|
24
|
+
|
|
25
|
+
def spots(nodes) = "#{subject.file}:#{nodes.map { it.location.start_line }.uniq.join(", ")}"
|
|
26
|
+
|
|
27
|
+
def evidence = []
|
|
28
|
+
|
|
29
|
+
def tally(name, lines) = "#{name} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
|
|
30
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira::Smells::Conditions
|
|
6
|
+
TESTED = [Prism::IfNode, Prism::UnlessNode, Prism::CaseNode, Prism::AndNode, Prism::OrNode].freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def tested?(node) = TESTED.include?(node.class)
|
|
11
|
+
|
|
12
|
+
def fence?(node) = Hashira::Smells::Scope::FENCES.include?(node.class)
|
|
13
|
+
|
|
14
|
+
def couple?(node) = node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)
|
|
15
|
+
|
|
16
|
+
def condition(node)
|
|
17
|
+
case node
|
|
18
|
+
when Prism::IfNode, Prism::UnlessNode, Prism::CaseNode then node.predicate
|
|
19
|
+
when Prism::AndNode, Prism::OrNode then node.left
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def branches(node)
|
|
24
|
+
return fork(node) if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
|
|
25
|
+
return node.conditions + [node.consequent] if node.is_a?(Prism::CaseNode)
|
|
26
|
+
couple?(node) ? [node.right] : [node.body]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fork(node)
|
|
30
|
+
[node.statements, node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def nested(roots) = roots.compact.flat_map { seek(it) }
|
|
34
|
+
|
|
35
|
+
def seek(node)
|
|
36
|
+
return [node] if tested?(node)
|
|
37
|
+
fence?(node) ? [] : node.compact_child_nodes.flat_map { seek(it) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def plain(node)
|
|
41
|
+
return [] if tested?(node) || fence?(node)
|
|
42
|
+
[node] + node.compact_child_nodes.flat_map { plain(it) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module Smells
|
|
7
|
+
module Parameters
|
|
8
|
+
NAMELESS = [Prism::ForwardingParameterNode, Prism::ImplicitRestNode, Prism::NoKeywordsParameterNode].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def names(def_node) = parts(def_node).flat_map { expand(it) }
|
|
13
|
+
|
|
14
|
+
def arguments(def_node) = parts(def_node).grep_v(Prism::BlockParameterNode).flat_map { expand(it) }
|
|
15
|
+
|
|
16
|
+
def parts(def_node)
|
|
17
|
+
node = def_node.parameters
|
|
18
|
+
return [] unless node
|
|
19
|
+
node.requireds + node.optionals + node.posts + node.keywords +
|
|
20
|
+
[node.rest, node.keyword_rest, node.block].compact
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def expand(part)
|
|
24
|
+
case part
|
|
25
|
+
when Prism::MultiTargetNode then pieces(part).flat_map { expand(it) }
|
|
26
|
+
when *NAMELESS then []
|
|
27
|
+
else [part.name].compact
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def pieces(part) = part.lefts + Array(part.rest&.expression) + part.rights
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
MethodContext =
|
|
35
|
+
Data.define(:owner, :node, :file, :section) do
|
|
36
|
+
def subject = "#{owner}#{singleton? ? "." : "#"}#{node.name}"
|
|
37
|
+
|
|
38
|
+
def line = node.location.start_line
|
|
39
|
+
|
|
40
|
+
def singleton? = node.receiver.is_a?(Prism::Node) || section == :singleton
|
|
41
|
+
|
|
42
|
+
def mixin? = section == :module_function
|
|
43
|
+
|
|
44
|
+
def public? = section == :public && !singleton?
|
|
45
|
+
|
|
46
|
+
def parameters = Parameters.names(node)
|
|
47
|
+
|
|
48
|
+
def arguments = Parameters.arguments(node)
|
|
49
|
+
|
|
50
|
+
def site = "#{file}:#{line}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
TypeContext =
|
|
54
|
+
Data.define(:name, :node, :kind, :file, :defs) do
|
|
55
|
+
def line = node.location.start_line
|
|
56
|
+
|
|
57
|
+
def subject = name
|
|
58
|
+
|
|
59
|
+
def owned = defs.reject(&:singleton?)
|
|
60
|
+
|
|
61
|
+
def site = "#{file}:#{line}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Smells::ControlParameter < Hashira::Smells::Check
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def smelly? = culprits.any?
|
|
7
|
+
|
|
8
|
+
def culprits = @culprits ||= subject.parameters.filter_map { |name| culprit(name) }
|
|
9
|
+
|
|
10
|
+
def culprit(name)
|
|
11
|
+
lines = spots(name).uniq
|
|
12
|
+
[name, lines] unless lines.empty?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def spots(name)
|
|
16
|
+
Hashira::Smells::ParamCheck.new(subject.node, name).matches.map { it.location.start_line }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def detail = { site:, names: culprits.map { |name, _lines| name } }
|
|
20
|
+
|
|
21
|
+
def evidence = culprits.map { |name, lines| tally(name, lines) }
|
|
22
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Smells::DataClump < Hashira::Smells::Check
|
|
4
|
+
MAX_COPIES = 2
|
|
5
|
+
|
|
6
|
+
MIN_SIZE = 2
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def smelly? = clumps.any?
|
|
11
|
+
|
|
12
|
+
def candidates = @candidates ||= subject.owned.map { [it, it.arguments.sort] }
|
|
13
|
+
|
|
14
|
+
def clumps = @clumps ||= shared.map { |clump| [clump, holders(clump)] }
|
|
15
|
+
|
|
16
|
+
def shared = candidates.combination(MAX_COPIES + 1).filter_map { |group| clump(group) }.uniq
|
|
17
|
+
|
|
18
|
+
def clump(group)
|
|
19
|
+
names = group.map(&:last).inject(:&)
|
|
20
|
+
names if names.size >= MIN_SIZE
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def holders(clump) = candidates.filter_map { |method, names| method if (clump - names).empty? }
|
|
24
|
+
|
|
25
|
+
def evidence = clumps.map { |clump, holders| row(clump, holders) }
|
|
26
|
+
|
|
27
|
+
def row(clump, holders)
|
|
28
|
+
"(#{clump.join(", ")}) → #{holders.size} methods: #{holders.map { |holder| holder.node.name }.join(", ")}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::DuplicateMethodCall < Hashira::Smells::Check
|
|
6
|
+
LIMIT = 1
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def smelly? = repeats.any?
|
|
11
|
+
|
|
12
|
+
def calls = @calls ||= Hashira::Smells::Scope.inside(subject.node).grep(Prism::CallNode)
|
|
13
|
+
|
|
14
|
+
def plain?(node)
|
|
15
|
+
!node.receiver && !node.arguments && !node.block.is_a?(Prism::BlockArgumentNode)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def repeats
|
|
19
|
+
@repeats ||= usual.reject { |_handle, nodes| whole.value?(nodes) }.merge(whole)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def usual
|
|
23
|
+
calls.reject { plain?(it) || it.name == :new }
|
|
24
|
+
.group_by { handle(it) }.select { |_handle, group| group.size > LIMIT }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def whole
|
|
28
|
+
@whole ||=
|
|
29
|
+
calls.select { it.block.is_a?(Prism::BlockNode) }
|
|
30
|
+
.group_by { it.slice.gsub(/\s+/, " ") }.select { |_handle, group| group.size > LIMIT }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def handle(node) = "#{title(node)}#{signature(node)}"
|
|
34
|
+
|
|
35
|
+
def title(node)
|
|
36
|
+
[node.receiver&.slice, node.name].compact.join(node.safe_navigation? ? "&." : ".")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def signature(node)
|
|
40
|
+
wrap(([node.arguments&.slice] + pass(node.block)).compact)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def pass(block) = block.is_a?(Prism::BlockArgumentNode) ? [block.slice] : []
|
|
44
|
+
|
|
45
|
+
def wrap(parts) = parts.empty? ? "" : "(#{parts.join(", ")})"
|
|
46
|
+
|
|
47
|
+
def evidence
|
|
48
|
+
repeats.map { |handle, nodes| "#{handle} × #{nodes.size} (#{stamp(lines(nodes))})" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def lines(nodes) = nodes.map { it.location.start_line }.uniq
|
|
52
|
+
|
|
53
|
+
def stamp(lines) = "line#{"s" if lines.size > 1} #{lines.join(", ")}"
|
|
54
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Smells::FeatureEnvy < Hashira::Smells::Check
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def smelly?
|
|
7
|
+
!subject.singleton? && !subject.mixin? && refs.ego.positive? && envied.any?
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def refs = @refs ||= Hashira::Smells::Refs.new(subject.node)
|
|
11
|
+
|
|
12
|
+
def envied = @envied ||= refs.envious
|
|
13
|
+
|
|
14
|
+
def detail = { site:, names: envied }
|
|
15
|
+
|
|
16
|
+
def evidence = envied.map { |name| tally(name, refs.lines(name).uniq) }
|
|
17
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::InstanceVariableAssumption < Hashira::Smells::Check
|
|
6
|
+
SETTERS = [
|
|
7
|
+
Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode,
|
|
8
|
+
Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode,
|
|
9
|
+
Prism::InstanceVariableTargetNode
|
|
10
|
+
].freeze
|
|
11
|
+
|
|
12
|
+
MAYBES = [
|
|
13
|
+
Prism::LocalVariableOrWriteNode, Prism::InstanceVariableOrWriteNode,
|
|
14
|
+
Prism::ClassVariableOrWriteNode, Prism::GlobalVariableOrWriteNode,
|
|
15
|
+
Prism::ConstantOrWriteNode, Prism::ConstantPathOrWriteNode,
|
|
16
|
+
Prism::CallOrWriteNode, Prism::IndexOrWriteNode
|
|
17
|
+
].freeze
|
|
18
|
+
|
|
19
|
+
module Harvest
|
|
20
|
+
module_function
|
|
21
|
+
|
|
22
|
+
def reads(node)
|
|
23
|
+
return [] if MAYBES.include?(node.class)
|
|
24
|
+
below = node.compact_child_nodes.flat_map { reads(it) }
|
|
25
|
+
node.is_a?(Prism::InstanceVariableReadNode) ? below + [node.name] : below
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def smelly? = subject.kind == :class && assumed.any?
|
|
32
|
+
|
|
33
|
+
def assumed = @assumed ||= (read - prepared).uniq.sort
|
|
34
|
+
|
|
35
|
+
def read = subject.owned.flat_map { Harvest.reads(it.node) }
|
|
36
|
+
|
|
37
|
+
def prepared
|
|
38
|
+
starters.flat_map { Hashira::Smells::Scope.inside(it.node) }.select { SETTERS.include?(it.class) }.map(&:name)
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
def starters = subject.owned.select { it.node.name == :initialize }
|
|
42
|
+
|
|
43
|
+
def evidence = assumed.map(&:to_s)
|
|
44
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::ManualDispatch < Hashira::Smells::Check
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def smelly? = sightings.any?
|
|
9
|
+
|
|
10
|
+
def sightings
|
|
11
|
+
@sightings ||= Hashira::Smells::Scope.inside(subject.node)
|
|
12
|
+
.select { it.is_a?(Prism::CallNode) && it.name == :respond_to? }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def detail = { site: spots(sightings) }
|
|
16
|
+
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::NilCheck < Hashira::Smells::Check
|
|
6
|
+
EQUALITY = %i[== ===].freeze
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def smelly? = checks.any?
|
|
11
|
+
|
|
12
|
+
def checks = @checks ||= Hashira::Smells::Scope.inside(subject.node).select { check?(it) }
|
|
13
|
+
|
|
14
|
+
def check?(node)
|
|
15
|
+
case node
|
|
16
|
+
when Prism::CallNode then query?(node)
|
|
17
|
+
when Prism::WhenNode then node.conditions.any?(Prism::NilNode)
|
|
18
|
+
else false
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def query?(node)
|
|
23
|
+
name = node.name
|
|
24
|
+
name == :nil? || (EQUALITY.include?(name) && equated?(node))
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def equated?(node) = sides(node).any?(Prism::NilNode)
|
|
28
|
+
|
|
29
|
+
def sides(node) = [node.receiver] + (node.arguments&.arguments || [])
|
|
30
|
+
|
|
31
|
+
def detail = { site: spots(checks) }
|
|
32
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::ParamCheck
|
|
6
|
+
COMPARISONS = %i[== != =~].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(node, name)
|
|
9
|
+
@node = node
|
|
10
|
+
@name = name
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def matches
|
|
14
|
+
return [] if legitimate?
|
|
15
|
+
nested.flat_map(&:matches) + tested
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def legitimate?
|
|
19
|
+
absolved? || working? || nested.any?(&:legitimate?)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
private
|
|
23
|
+
|
|
24
|
+
def nested
|
|
25
|
+
@nested ||= Hashira::Smells::Conditions.nested(branches).map { self.class.new(it, @name) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def branches = Hashira::Smells::Conditions.branches(@node)
|
|
29
|
+
|
|
30
|
+
def predicate
|
|
31
|
+
@predicate ||= spread(Hashira::Smells::Conditions.condition(@node))
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def spread(condition) = condition ? Hashira::Analysis::NodeWalk.collect(condition) : []
|
|
35
|
+
|
|
36
|
+
def tested = reads(predicate)
|
|
37
|
+
|
|
38
|
+
def working?
|
|
39
|
+
reads(branches.compact.flat_map { Hashira::Smells::Conditions.plain(it) }).any?
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def absolved?
|
|
43
|
+
predicate.grep(Prism::CallNode).any? { absolves?(it) }
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def absolves?(call)
|
|
47
|
+
!COMPARISONS.include?(call.name) && reads(Hashira::Analysis::NodeWalk.collect(call)).any?
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def reads(nodes) = nodes.grep(Prism::LocalVariableReadNode).select { it.name == @name }
|
|
51
|
+
end
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::Refs
|
|
6
|
+
SELVES = [
|
|
7
|
+
Prism::SelfNode, Prism::SuperNode, Prism::ForwardingSuperNode,
|
|
8
|
+
Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode,
|
|
9
|
+
Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode,
|
|
10
|
+
Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableTargetNode
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(def_node)
|
|
14
|
+
@tallies = {}
|
|
15
|
+
Hashira::Smells::Scope.inside(def_node).each { record(it) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def ego = lines(:self).size
|
|
19
|
+
|
|
20
|
+
def lines(name) = @tallies.fetch(name, [])
|
|
21
|
+
|
|
22
|
+
def envious
|
|
23
|
+
peak = @tallies.values.map(&:size).max
|
|
24
|
+
names = @tallies.filter_map { |name, sightings| name if sightings.size == peak }
|
|
25
|
+
names.include?(:self) ? [] : names
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def record(node)
|
|
31
|
+
return note(:self, node) if selfish?(node)
|
|
32
|
+
case node
|
|
33
|
+
when Prism::CallNode then named(node)
|
|
34
|
+
when Prism::LocalVariableOperatorWriteNode then note(node.name, node)
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def selfish?(node)
|
|
39
|
+
SELVES.include?(node.class) || implicit?(node)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def implicit?(node) = node.is_a?(Prism::CallNode) && !node.receiver
|
|
43
|
+
|
|
44
|
+
def named(node)
|
|
45
|
+
case (receiver = node.receiver)
|
|
46
|
+
when Prism::SelfNode then note(:self, node)
|
|
47
|
+
when Prism::LocalVariableReadNode, Prism::LocalVariableWriteNode
|
|
48
|
+
note(receiver.name, node) unless node.name == :new
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def note(name, node) = (@tallies[name] ||= []) << node.location.start_line
|
|
53
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::RepeatedConditional < Hashira::Smells::Check
|
|
6
|
+
LIMIT = 2
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def smelly? = subject.kind == :class && repeats.any?
|
|
11
|
+
|
|
12
|
+
def repeats
|
|
13
|
+
@repeats ||= predicates.group_by(&:slice).except("block_given?").select { |_test, nodes| nodes.size > LIMIT }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def predicates = Hashira::Smells::Scope.sweep(subject.node).filter_map { predicate(it) }
|
|
17
|
+
|
|
18
|
+
def predicate(node)
|
|
19
|
+
node.predicate if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode) || node.is_a?(Prism::CaseNode)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def detail = { site:, count: repeats.values.map(&:size).max }
|
|
23
|
+
|
|
24
|
+
def evidence = repeats.map { |test, nodes| row(test, nodes) }
|
|
25
|
+
|
|
26
|
+
def row(test, nodes)
|
|
27
|
+
"#{test} × #{nodes.size} (lines #{nodes.map { |node| node.location.start_line }.join(", ")})"
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Smells::Report
|
|
4
|
+
def initialize(project, trees)
|
|
5
|
+
@types = Hashira::Smells::Census.new(project, trees).types
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def findings = @findings ||= sniff(@types, judges) + sniff(@types.flat_map(&:defs), probes)
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def sniff(subjects, checks) = subjects.flat_map { |subject| verdicts(subject, checks) }
|
|
13
|
+
|
|
14
|
+
def verdicts(subject, checks) = checks.filter_map { |check| check.new(subject).finding }
|
|
15
|
+
|
|
16
|
+
def judges
|
|
17
|
+
[
|
|
18
|
+
Hashira::Smells::DataClump, Hashira::Smells::InstanceVariableAssumption,
|
|
19
|
+
Hashira::Smells::ModuleInitialize, Hashira::Smells::RepeatedConditional,
|
|
20
|
+
Hashira::Smells::TooManyInstanceVariables
|
|
21
|
+
]
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def probes
|
|
25
|
+
[
|
|
26
|
+
Hashira::Smells::ControlParameter, Hashira::Smells::DuplicateMethodCall,
|
|
27
|
+
Hashira::Smells::FeatureEnvy, Hashira::Smells::ManualDispatch,
|
|
28
|
+
Hashira::Smells::NilCheck, Hashira::Smells::UtilityFunction
|
|
29
|
+
]
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira::Smells::Scope
|
|
6
|
+
TYPES = [Prism::ClassNode, Prism::ModuleNode, Prism::SingletonClassNode].freeze
|
|
7
|
+
|
|
8
|
+
FENCES = (TYPES + [Prism::DefNode]).freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def inside(node) = below(node, FENCES)
|
|
13
|
+
|
|
14
|
+
def sweep(node) = below(node, TYPES)
|
|
15
|
+
|
|
16
|
+
def below(root, fences)
|
|
17
|
+
found = []
|
|
18
|
+
visit(root, fences) { found << it }
|
|
19
|
+
found
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def visit(root, fences, &)
|
|
23
|
+
root.compact_child_nodes.each do |child|
|
|
24
|
+
next if fences.include?(child.class)
|
|
25
|
+
yield(child)
|
|
26
|
+
visit(child, fences, &)
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::TooManyInstanceVariables < Hashira::Smells::Check
|
|
6
|
+
LIMIT = 4
|
|
7
|
+
|
|
8
|
+
COUNTED = [
|
|
9
|
+
Prism::InstanceVariableWriteNode, Prism::InstanceVariableAndWriteNode,
|
|
10
|
+
Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableTargetNode
|
|
11
|
+
].freeze
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def smelly? = subject.kind == :class && names.size > LIMIT
|
|
16
|
+
|
|
17
|
+
def names
|
|
18
|
+
@names ||= Hashira::Smells::Scope.sweep(subject.node).select { COUNTED.include?(it.class) }.map(&:name).uniq.sort
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def detail = { site:, count: names.size }
|
|
22
|
+
|
|
23
|
+
def evidence = names.map(&:to_s)
|
|
24
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::UtilityFunction < Hashira::Smells::Check
|
|
6
|
+
private
|
|
7
|
+
|
|
8
|
+
def smelly? = subject.public? && calls? && refs.ego.zero?
|
|
9
|
+
|
|
10
|
+
def refs = Hashira::Smells::Refs.new(subject.node)
|
|
11
|
+
|
|
12
|
+
def calls? = Hashira::Smells::Scope.inside(subject.node).any?(Prism::CallNode)
|
|
13
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::Visibility
|
|
6
|
+
MARKERS = %i[public private protected module_function].freeze
|
|
7
|
+
|
|
8
|
+
def initialize(type_node)
|
|
9
|
+
@section = :public
|
|
10
|
+
@overrides = {}
|
|
11
|
+
@found = []
|
|
12
|
+
scan(statements(type_node))
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def entries = @found.map { |node, section| [node, @overrides.fetch(node.name, section)] }
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def statements(node)
|
|
20
|
+
body = node.body
|
|
21
|
+
body.is_a?(Prism::StatementsNode) ? body.body : []
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def scan(nodes) = nodes.each { classify(it) }
|
|
25
|
+
|
|
26
|
+
def classify(node)
|
|
27
|
+
case node
|
|
28
|
+
when Prism::DefNode then @found << [node, @section]
|
|
29
|
+
when Prism::CallNode then heed(node)
|
|
30
|
+
else descend(node)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def descend(node)
|
|
35
|
+
case node
|
|
36
|
+
when Prism::SingletonClassNode then shadow(node)
|
|
37
|
+
when Prism::ClassNode, Prism::ModuleNode, Prism::ConstantWriteNode then nil
|
|
38
|
+
else scan(node.compact_child_nodes)
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def heed(node)
|
|
43
|
+
bare?(node) ? switch(node.name, node.arguments) : scan(node.compact_child_nodes)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def bare?(node) = MARKERS.include?(node.name) && !node.receiver
|
|
47
|
+
|
|
48
|
+
def switch(name, arguments)
|
|
49
|
+
arguments ? tag(name, arguments.arguments) : (@section = name)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def tag(section, arguments) = arguments.each { note(it, section) }
|
|
53
|
+
|
|
54
|
+
def note(argument, section)
|
|
55
|
+
case argument
|
|
56
|
+
when Prism::DefNode then @found << [argument, section]
|
|
57
|
+
when Prism::SymbolNode then @overrides[argument.unescaped.to_sym] = section
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def shadow(node)
|
|
62
|
+
statements(node).each { @found << [it, :singleton] if it.is_a?(Prism::DefNode) }
|
|
63
|
+
end
|
|
64
|
+
end
|
data/lib/hashira/version.rb
CHANGED