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.
Files changed (88) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +86 -25
  4. data/lib/hashira/analysis/finding.rb +2 -2
  5. data/lib/hashira/ci/ratchet.rb +5 -9
  6. data/lib/hashira/cli/fail_on.rb +15 -5
  7. data/lib/hashira/cli/run.rb +8 -4
  8. data/lib/hashira/cli/usage.rb +8 -5
  9. data/lib/hashira/complexity/boolean_run.rb +5 -3
  10. data/lib/hashira/complexity/cognitive_score.rb +21 -14
  11. data/lib/hashira/complexity/if_chain.rb +14 -14
  12. data/lib/hashira/complexity/method_finding.rb +3 -20
  13. data/lib/hashira/complexity/rescue_scan.rb +9 -9
  14. data/lib/hashira/complexity/{analyzer.rb → scores.rb} +6 -5
  15. data/lib/hashira/coupling/audiences.rb +49 -0
  16. data/lib/hashira/{analysis → coupling}/catalog.rb +3 -3
  17. data/lib/hashira/{analysis → coupling}/census.rb +17 -8
  18. data/lib/hashira/{analysis → coupling}/constant_registry.rb +6 -2
  19. data/lib/hashira/coupling/cycle_findings.rb +31 -0
  20. data/lib/hashira/{analysis → coupling}/cycle_search.rb +1 -1
  21. data/lib/hashira/{analysis → coupling}/cycles.rb +2 -2
  22. data/lib/hashira/{analysis → coupling}/definition.rb +1 -1
  23. data/lib/hashira/{analysis → coupling}/definitions.rb +1 -1
  24. data/lib/hashira/{analysis → coupling}/edge.rb +1 -1
  25. data/lib/hashira/{analysis → coupling}/edge_map.rb +10 -10
  26. data/lib/hashira/{analysis → coupling}/folder_placement.rb +2 -2
  27. data/lib/hashira/{analysis → coupling}/folding.rb +4 -2
  28. data/lib/hashira/{analysis → coupling}/graph.rb +18 -8
  29. data/lib/hashira/{analysis → coupling}/metric.rb +1 -1
  30. data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
  31. data/lib/hashira/{analysis → coupling}/namespace_placement.rb +3 -3
  32. data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +1 -1
  33. data/lib/hashira/{analysis → coupling}/naming.rb +2 -2
  34. data/lib/hashira/{analysis → coupling}/no_folding.rb +1 -1
  35. data/lib/hashira/{analysis → coupling}/placement.rb +3 -3
  36. data/lib/hashira/coupling/references.rb +55 -0
  37. data/lib/hashira/coupling/report.rb +28 -0
  38. data/lib/hashira/coupling/roll_call.rb +45 -0
  39. data/lib/hashira/coupling/roll_call_findings.rb +30 -0
  40. data/lib/hashira/{analysis → coupling}/roster.rb +2 -2
  41. data/lib/hashira/{analysis → coupling}/rule.rb +1 -1
  42. data/lib/hashira/{analysis/resolver.rb → coupling/scope.rb} +2 -2
  43. data/lib/hashira/{analysis → coupling}/sdp_check.rb +1 -1
  44. data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
  45. data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
  46. data/lib/hashira/coupling/words.rb +29 -0
  47. data/lib/hashira/diagram/{renderer.rb → source.rb} +2 -2
  48. data/lib/hashira/duplication/{analyzer.rb → clones.rb} +3 -3
  49. data/lib/hashira/duplication/{clusterer.rb → clusters.rb} +3 -3
  50. data/lib/hashira/duplication/delta.rb +6 -14
  51. data/lib/hashira/duplication/duplication_finding.rb +6 -10
  52. data/lib/hashira/duplication/{extractor.rb → harvest.rb} +1 -1
  53. data/lib/hashira/duplication/sequence.rb +3 -1
  54. data/lib/hashira/duplication/similarity.rb +5 -1
  55. data/lib/hashira/duplication/variance.rb +3 -3
  56. data/lib/hashira/hotspots/rollup.rb +1 -1
  57. data/lib/hashira/pipeline.rb +15 -10
  58. data/lib/hashira/report/complexity_table.rb +1 -1
  59. data/lib/hashira/report/finding_lines.rb +1 -1
  60. data/lib/hashira/report/json.rb +5 -3
  61. data/lib/hashira/report/phrases.rb +98 -0
  62. data/lib/hashira/report/smell_phrases.rb +68 -0
  63. data/lib/hashira/smells/census.rb +32 -0
  64. data/lib/hashira/smells/check.rb +30 -0
  65. data/lib/hashira/smells/conditions.rb +44 -0
  66. data/lib/hashira/smells/contexts.rb +64 -0
  67. data/lib/hashira/smells/control_parameter.rb +22 -0
  68. data/lib/hashira/smells/data_clump.rb +30 -0
  69. data/lib/hashira/smells/duplicate_method_call.rb +54 -0
  70. data/lib/hashira/smells/feature_envy.rb +17 -0
  71. data/lib/hashira/smells/instance_variable_assumption.rb +44 -0
  72. data/lib/hashira/smells/manual_dispatch.rb +16 -0
  73. data/lib/hashira/smells/module_initialize.rb +9 -0
  74. data/lib/hashira/smells/nil_check.rb +32 -0
  75. data/lib/hashira/smells/param_check.rb +51 -0
  76. data/lib/hashira/smells/refs.rb +53 -0
  77. data/lib/hashira/smells/repeated_conditional.rb +29 -0
  78. data/lib/hashira/smells/report.rb +31 -0
  79. data/lib/hashira/smells/scope.rb +29 -0
  80. data/lib/hashira/smells/too_many_instance_variables.rb +24 -0
  81. data/lib/hashira/smells/utility_function.rb +13 -0
  82. data/lib/hashira/smells/visibility.rb +64 -0
  83. data/lib/hashira/version.rb +1 -1
  84. data/lib/hashira.rb +65 -30
  85. metadata +60 -31
  86. data/lib/hashira/analysis/cycle_findings.rb +0 -38
  87. data/lib/hashira/analysis/references.rb +0 -40
  88. 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Smells::ModuleInitialize < Hashira::Smells::Check
4
+ private
5
+
6
+ def smelly? = subject.kind == :module && flagged
7
+
8
+ def flagged = subject.owned.any? { it.node.name == :initialize }
9
+ 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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashira
4
- VERSION = "0.4.0"
4
+ VERSION = "0.5.0"
5
5
  end