hashira 0.5.1 → 0.7.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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -0
  3. data/README.md +131 -56
  4. data/lib/hashira/analysis/finding.rb +4 -0
  5. data/lib/hashira/churn.rb +10 -2
  6. data/lib/hashira/ci/accepted.rb +1 -4
  7. data/lib/hashira/ci/baseline.rb +28 -5
  8. data/lib/hashira/ci/diff.rb +15 -4
  9. data/lib/hashira/ci/finding_diff_report.rb +10 -0
  10. data/lib/hashira/ci/gate.rb +10 -2
  11. data/lib/hashira/ci/improvement.rb +1 -1
  12. data/lib/hashira/ci/ratchet.rb +33 -8
  13. data/lib/hashira/ci/ratchet_report.rb +4 -2
  14. data/lib/hashira/ci/scope.rb +11 -0
  15. data/lib/hashira/ci/status.rb +7 -0
  16. data/lib/hashira/cli/arguments.rb +6 -1
  17. data/lib/hashira/cli/fail_on.rb +12 -2
  18. data/lib/hashira/cli/flags.rb +13 -0
  19. data/lib/hashira/cli/needs.rb +34 -0
  20. data/lib/hashira/cli/options.rb +14 -3
  21. data/lib/hashira/cli/run.rb +32 -5
  22. data/lib/hashira/cli/top.rb +13 -0
  23. data/lib/hashira/cli/usage.rb +5 -1
  24. data/lib/hashira/cli.rb +24 -5
  25. data/lib/hashira/coupling/metric.rb +7 -1
  26. data/lib/hashira/diagram/dot.rb +9 -6
  27. data/lib/hashira/diagram/mermaid.rb +11 -8
  28. data/lib/hashira/diagram/source.rb +5 -2
  29. data/lib/hashira/pipeline.rb +15 -19
  30. data/lib/hashira/project.rb +19 -5
  31. data/lib/hashira/report/columns.rb +48 -0
  32. data/lib/hashira/report/complexity_table.rb +12 -16
  33. data/lib/hashira/report/dependency_map.rb +1 -0
  34. data/lib/hashira/report/hotspot_table.rb +6 -14
  35. data/lib/hashira/report/json.rb +9 -2
  36. data/lib/hashira/report/metrics_table.rb +19 -20
  37. data/lib/hashira/report/notices.rb +37 -0
  38. data/lib/hashira/report/phrases.rb +2 -0
  39. data/lib/hashira/report/smell_phrases.rb +6 -0
  40. data/lib/hashira/report/text.rb +24 -9
  41. data/lib/hashira/report/view.rb +4 -1
  42. data/lib/hashira/smells/boundary_sprawl.rb +43 -0
  43. data/lib/hashira/smells/census.rb +4 -1
  44. data/lib/hashira/smells/contexts.rb +1 -1
  45. data/lib/hashira/smells/feature_envy.rb +3 -1
  46. data/lib/hashira/smells/foreign.rb +131 -0
  47. data/lib/hashira/smells/ownership.rb +55 -0
  48. data/lib/hashira/smells/report.rb +10 -2
  49. data/lib/hashira/trees.rb +22 -0
  50. data/lib/hashira/version.rb +1 -1
  51. data/lib/hashira.rb +6 -0
  52. metadata +17 -6
@@ -2,35 +2,31 @@
2
2
 
3
3
  class Hashira::Report::ComplexityTable
4
4
  TOP = 10
5
- METHOD_ROW = "%-44s %4s %6s %s"
6
- CLASS_ROW = "%-32s %5s %8s %6s"
5
+ METHOD_HEADERS = %w[method Cog Calls Loc].freeze
6
+ CLASS_HEADERS = %w[class Cog Methods Peak].freeze
7
7
  METHOD_TITLE = "Cognitive complexity — worst methods (Cog = how hard to read, Calls = message sends)"
8
8
  CLASS_TITLE = "Per-class rollup (Cog total survives extract-method; Peak is the worst method it hides)"
9
9
 
10
- def initialize(complexity, io: $stdout)
10
+ def initialize(complexity, top: TOP, io: $stdout)
11
11
  @complexity = complexity
12
+ @top = top
12
13
  @io = io
13
14
  end
14
15
 
15
16
  def print
16
- section(@complexity.ranked, METHOD_ROW, %w[method Cog Calls Loc], METHOD_TITLE)
17
- section(@complexity.classes, CLASS_ROW, %w[class Cog Methods Peak], CLASS_TITLE)
17
+ section(@complexity.ranked, METHOD_HEADERS, METHOD_TITLE)
18
+ section(@complexity.classes, CLASS_HEADERS, CLASS_TITLE)
18
19
  end
19
20
 
20
21
  private
21
22
 
22
- def section(scores, row_format, columns, title)
23
- heading(row_format, columns, title)
24
- ranked(scores).each { @io.puts(format(row_format, *it.cells)) }
25
- @io.puts
26
- end
27
-
28
- def heading(row_format, columns, title)
29
- header = format(row_format, *columns)
23
+ def section(scores, headers, title)
24
+ rows = ranked(scores)
25
+ return if rows.empty?
30
26
  @io.puts("#{title}:\n\n")
31
- @io.puts(header)
32
- @io.puts("-" * header.length)
27
+ Hashira::Report::Columns.new(headers, rows.map(&:cells), io: @io).print
28
+ @io.puts
33
29
  end
34
30
 
35
- def ranked(scores) = scores.select { it.cognitive.positive? }.first(TOP)
31
+ def ranked(scores) = scores.select { it.cognitive.positive? }.first(@top)
36
32
  end
@@ -9,6 +9,7 @@ class Hashira::Report::DependencyMap
9
9
  def print
10
10
  @io.puts("Dependencies (DependsUpon(refs) -> | <- UsedBy):")
11
11
  @graph.packages.sort.each { @io.puts(row(it)) }
12
+ @io.puts
12
13
  end
13
14
 
14
15
  private
@@ -2,32 +2,24 @@
2
2
 
3
3
  class Hashira::Report::HotspotTable
4
4
  TOP = 10
5
- ROW = "%-46s %5s %5s %6s %7s"
5
+ HEADERS = %w[file Cog Dup Churn Rank].freeze
6
6
 
7
- def initialize(hotspots, io: $stdout)
7
+ def initialize(hotspots, top: TOP, io: $stdout)
8
8
  @hotspots = hotspots
9
+ @top = top
9
10
  @io = io
10
11
  end
11
12
 
12
13
  def print
13
14
  return if ranked.empty?
14
- heading
15
- rows
15
+ @io.puts("Hotspots — cost × churn (where refactoring pays the most):\n\n")
16
+ Hashira::Report::Columns.new(HEADERS, ranked.map(&:cells), io: @io).print
16
17
  legend
17
18
  end
18
19
 
19
20
  private
20
21
 
21
- def ranked = @ranked ||= @hotspots.files.first(TOP)
22
-
23
- def rows = ranked.each { @io.puts(format(ROW, *it.cells)) }
24
-
25
- def heading
26
- @io.puts("Hotspots — cost × churn (where refactoring pays the most):\n\n")
27
- header = format(ROW, *%w[file Cog Dup Churn Rank])
28
- @io.puts(header)
29
- @io.puts("-" * header.length)
30
- end
22
+ def ranked = @ranked ||= @hotspots.files.first(@top)
31
23
 
32
24
  def legend
33
25
  @io.puts("\nLegend: Cog cognitive complexity, Dup mass of the clones the file carries,")
@@ -8,14 +8,21 @@ class Hashira::Report::Json
8
8
  @io = io
9
9
  end
10
10
 
11
+ SCHEMA = 1
12
+
11
13
  def print
12
- @io.puts(JSON.pretty_generate(payload))
14
+ @io.puts(@view.compact ? JSON.generate(payload) : JSON.pretty_generate(payload))
13
15
  0
14
16
  end
15
17
 
16
18
  private
17
19
 
18
- def payload = base.merge(coupling).merge(sections.compact)
20
+ def payload = about.merge(base).merge(coupling).merge(sections.compact)
21
+
22
+ def about
23
+ project = @view.project
24
+ { version: SCHEMA, packaging: @view.graph&.packaging, targets: project.directories, files: project.files.size }
25
+ end
19
26
 
20
27
  def base = { findings: @view.findings.all.map { rendered(it) }, accepted: accepted }
21
28
 
@@ -1,47 +1,46 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Report::MetricsTable
4
- def initialize(graph, io: $stdout)
4
+ TOP = 25
5
+
6
+ def initialize(graph, top: TOP, io: $stdout)
5
7
  @graph = graph
8
+ @top = top
6
9
  @io = io
7
10
  end
8
11
 
9
- LIMIT = 25
12
+ HEADERS = %w[package TC Ca Ce I Cyc].freeze
10
13
 
11
14
  def print
12
- heading
13
- kept.each { |package, metric| @io.puts(row(package, metric)) }
15
+ Hashira::Report::Columns.new(HEADERS, kept.map { |package, metric| row(package, metric) }, io: @io).print
14
16
  note unless spared.empty?
17
+ more unless over.zero?
15
18
  legend
16
19
  end
17
20
 
18
21
  private
19
22
 
20
- def ranked = @graph.metrics.sort_by { |_package, metric| metric.instability }
23
+ def ranked = @graph.metrics.sort_by { |_package, metric| metric.order }
21
24
 
22
- def split = @split ||= (ranked.size > LIMIT ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
25
+ def split = @split ||= (ranked.size > @top ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
23
26
 
24
- def kept = split.first
27
+ def kept = split.first.first(@top)
25
28
 
26
29
  def spared = split.last
27
30
 
31
+ def over = split.first.size - kept.size
32
+
28
33
  def leaf?(metric) = metric.types <= 1 && metric.efferent.zero? && metric.afferent <= 1
29
34
 
30
- def note
31
- @io.puts(" + #{spared.size} single-type leaf packages (TC ≤ 1, Ca ≤ 1, Ce = 0) — hidden for brevity")
32
- end
35
+ def note = @io.puts(" + #{leaves} (TC ≤ 1, Ca ≤ 1, Ce = 0) — hidden for brevity")
33
36
 
34
- def heading
35
- @io.puts(format("%-12s %3s %3s %3s %5s %-3s", *%w[package TC Ca Ce I Cyc]))
36
- @io.puts("-" * 40)
37
- end
37
+ def leaves = Hashira::Report::Phrases.count(spared.size, "single-type leaf package")
38
38
 
39
- def row(package, metric)
40
- format(
41
- "%-12s %3d %3d %3d %5.2f %-3s", package, *metric.to_h.values,
42
- (@graph.cycles.through?(package) ? "YES" : "-")
43
- )
44
- end
39
+ def more = @io.puts(" … and #{over} more — raise the cap with --top, or read them all with --json")
40
+
41
+ def row(package, metric) = [package, *metric.cells, cyc(package)]
42
+
43
+ def cyc(package) = @graph.cycles.through?(package) ? "YES" : "-"
45
44
 
46
45
  def legend
47
46
  @io.puts("\nLegend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),")
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Report::Notices
4
+ ISSUES = "https://github.com/giacope/hashira/issues"
5
+
6
+ def initialize(io: $stderr)
7
+ @io = io
8
+ end
9
+
10
+ def scanning(files) = interactive { @io.puts("hashira: reading #{files} files…") }
11
+
12
+ def finished(files, seconds) = interactive { @io.puts("hashira: #{files} files in #{seconds}s") }
13
+
14
+ def rails
15
+ @io.puts("hashira: this looks like a Rails root — `hashira app` reads the application, not just lib/")
16
+ end
17
+
18
+ def churn(label)
19
+ @io.puts("hashira: no git history for #{label} — hotspots are ranked by cost alone")
20
+ end
21
+
22
+ def crashed(error)
23
+ @io.puts("hashira: internal error — #{error.class}: #{error.message}")
24
+ @io.puts(" at #{error.backtrace.first}")
25
+ @io.puts(" this is a bug in hashira — please report it at #{ISSUES}")
26
+ end
27
+
28
+ def unparsed(count, sample)
29
+ @io.puts("hashira: #{count} of the files did not parse — #{sample}")
30
+ end
31
+
32
+ private
33
+
34
+ def interactive
35
+ yield if @io.tty?
36
+ end
37
+ end
@@ -81,6 +81,8 @@ module Hashira::Report::Phrases
81
81
 
82
82
  def score(value) = format("%.2f", value)
83
83
 
84
+ def count(number, noun) = "#{number} #{number == 1 ? noun : "#{noun}s"}"
85
+
84
86
  def clause(part) = "#{part[:users].join(", ")} #{verb(part)} #{part[:constants].join(", ")}"
85
87
 
86
88
  def verb(part)
@@ -19,6 +19,12 @@ module Hashira::Report::Phrases
19
19
  "Name the result in a local variable."
20
20
  end
21
21
 
22
+ def on_boundary_sprawl(finding)
23
+ detail = finding.detail
24
+ "#{detail[:count]} methods across #{detail[:files]} files each pick apart #{finding.package}'s " \
25
+ "internals. Front the boundary with one adapter the rest can lean on."
26
+ end
27
+
22
28
  def on_feature_envy(finding)
23
29
  detail = finding.detail
24
30
  names = detail[:names]
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Report::Text
4
+ FINDINGS = 25
5
+
4
6
  def initialize(view, io: $stdout)
5
7
  @view = view
6
8
  @io = io
@@ -19,31 +21,37 @@ class Hashira::Report::Text
19
21
  def coupling
20
22
  graph = @view.graph
21
23
  header(graph)
22
- Hashira::Report::MetricsTable.new(graph, io: @io).print
24
+ table(Hashira::Report::MetricsTable, graph)
23
25
  Hashira::Report::DependencyMap.new(graph, io: @io).print
24
26
  folded(graph.folds)
25
27
  end
26
28
 
27
29
  def folded(folds)
28
30
  return if folds.empty?
29
- @io.puts("\nFolded (single-type classes joined to their base or domain):")
31
+ @io.puts("Folded (single-type classes joined to their base or domain):")
30
32
  folds.each { @io.puts(" #{it[:from]} -> #{it[:to]} (#{it[:via]})") }
33
+ @io.puts
31
34
  end
32
35
 
33
- def complexity = Hashira::Report::ComplexityTable.new(@view.complexity, io: @io).print
36
+ def table(kind, subject) = kind.new(subject, top: @view.top || kind::TOP, io: @io).print
37
+
38
+ def complexity = table(Hashira::Report::ComplexityTable, @view.complexity)
34
39
 
35
- def hotspots = Hashira::Report::HotspotTable.new(@view.hotspots, io: @io).print
40
+ def hotspots = table(Hashira::Report::HotspotTable, @view.hotspots)
36
41
 
37
42
  def header(graph)
38
43
  packages = graph.packages.size
39
- @io.puts(banner(packages))
44
+ @io.puts(banner(graph.packaging, packages))
40
45
  caveat if packages == 1
41
46
  end
42
47
 
43
- def banner(packages)
44
- "Package (layer) metrics for #{@view.project.label} (#{packages} packages, #{total} files)\n\n"
48
+ def banner(grouping, packages)
49
+ "Package (#{grouping}) metrics for #{@view.project.label} " \
50
+ "(#{count(packages, "package")}, #{count(total, "file")})\n\n"
45
51
  end
46
52
 
53
+ def count(number, noun) = Hashira::Report::Phrases.count(number, noun)
54
+
47
55
  def total = @view.project.files.size
48
56
 
49
57
  def caveat
@@ -55,7 +63,7 @@ class Hashira::Report::Text
55
63
 
56
64
  def findings
57
65
  all = @view.findings.all
58
- @io.puts("\nFindings (#{all.size}):")
66
+ @io.puts("Findings (#{all.size}):")
59
67
  list(all)
60
68
  accepted
61
69
  @io.puts("\n Full evidence + machine format: hashira --json") unless all.empty?
@@ -63,7 +71,14 @@ class Hashira::Report::Text
63
71
 
64
72
  def list(all)
65
73
  return @io.puts(" none ✓ — structure is healthy") if all.empty?
66
- all.each { Hashira::Report::FindingLines.new(it, indent: " ", io: @io).emit }
74
+ shown = all.first(@view.top || FINDINGS)
75
+ shown.each { Hashira::Report::FindingLines.new(it, indent: " ", io: @io).emit }
76
+ elided(all.size - shown.size)
77
+ end
78
+
79
+ def elided(rest)
80
+ return if rest.zero?
81
+ @io.puts(" … and #{rest} more — raise the cap with --top, or read them all with --json")
67
82
  end
68
83
 
69
84
  def accepted
@@ -2,6 +2,9 @@
2
2
 
3
3
  module Hashira
4
4
  module Report
5
- View = Data.define(:project, :graph, :complexity, :duplication, :hotspots, :findings)
5
+ View =
6
+ Data.define(:project, :graph, :complexity, :duplication, :hotspots, :findings, :top, :compact) do
7
+ def initialize(top: nil, compact: nil, **) = super
8
+ end
6
9
  end
7
10
  end
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Smells::BoundarySprawl
4
+ KIND = "boundary_sprawl"
5
+
6
+ METHOD_FLOOR = 12
7
+
8
+ FILE_FLOOR = 3
9
+
10
+ SHOWN = 4
11
+
12
+ def initialize(subjects, ownership)
13
+ @subjects = subjects
14
+ @ownership = ownership
15
+ end
16
+
17
+ def findings
18
+ reaches.filter_map { |root, contexts| finding(root, contexts) }
19
+ end
20
+
21
+ private
22
+
23
+ def reaches
24
+ @subjects.each_with_object({}) do |subject, map|
25
+ Hashira::Smells::Foreign.new(subject, @ownership).reaches.each { (map[it] ||= []) << subject }
26
+ end
27
+ end
28
+
29
+ def finding(root, contexts)
30
+ files = contexts.map(&:file).uniq
31
+ build(root, contexts, files) if wide?(contexts.size, files.size)
32
+ end
33
+
34
+ def wide?(methods, files) = methods >= METHOD_FLOOR && files >= FILE_FLOOR
35
+
36
+ def build(root, contexts, files)
37
+ Hashira::Analysis::Finding.new(
38
+ kind: KIND, package: root,
39
+ detail: { count: contexts.size, files: files.size },
40
+ evidence: contexts.first(SHOWN).map(&:site)
41
+ )
42
+ end
43
+ end
@@ -6,8 +6,11 @@ class Hashira::Smells::Census
6
6
  def initialize(project, trees)
7
7
  @project = project
8
8
  @trees = trees
9
+ @ownership = Hashira::Smells::Ownership.new(trees.values)
9
10
  end
10
11
 
12
+ attr_reader :ownership
13
+
11
14
  def types = @trees.flat_map { |path, tree| harvest(@project.relative(path), tree) }
12
15
 
13
16
  private
@@ -26,7 +29,7 @@ class Hashira::Smells::Census
26
29
 
27
30
  def defs(name, node, file)
28
31
  Hashira::Smells::Visibility.new(node).entries.map do |def_node, section|
29
- Hashira::Smells::MethodContext.new(owner: name, node: def_node, file:, section:)
32
+ Hashira::Smells::MethodContext.new(owner: name, node: def_node, file:, section:, ownership: @ownership)
30
33
  end
31
34
  end
32
35
  end
@@ -32,7 +32,7 @@ module Hashira
32
32
  end
33
33
 
34
34
  MethodContext =
35
- Data.define(:owner, :node, :file, :section) do
35
+ Data.define(:owner, :node, :file, :section, :ownership) do
36
36
  def subject = "#{owner}#{singleton? ? "." : "#"}#{node.name}"
37
37
 
38
38
  def line = node.location.start_line
@@ -9,7 +9,9 @@ class Hashira::Smells::FeatureEnvy < Hashira::Smells::Check
9
9
 
10
10
  def refs = @refs ||= Hashira::Smells::Refs.new(subject.node)
11
11
 
12
- def envied = @envied ||= refs.envious
12
+ def envied = @envied ||= refs.envious.reject { foreign.dismiss?(it) }
13
+
14
+ def foreign = @foreign ||= Hashira::Smells::Foreign.new(subject, subject.ownership)
13
15
 
14
16
  def detail = { site:, names: envied }
15
17
 
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Smells::Foreign
6
+ TYPE_TESTS = %i[is_a? kind_of? instance_of?].freeze
7
+
8
+ KEYED_READS = %i[[] fetch values_at dig key?].freeze
9
+
10
+ KEYS = [Prism::StringNode, Prism::SymbolNode].freeze
11
+
12
+ LITERALS = [Prism::HashNode, Prism::KeywordHashNode, Prism::ArrayNode, Prism::StringNode].freeze
13
+
14
+ STATE = [
15
+ Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode,
16
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode,
17
+ Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableTargetNode
18
+ ].freeze
19
+
20
+ def initialize(subject, ownership)
21
+ node = subject.node
22
+ @body = Hashira::Smells::Scope.inside(node)
23
+ @tail = tail(node)
24
+ @ownership = ownership
25
+ end
26
+
27
+ def dismiss?(name)
28
+ convert? || fenced?(name) || wire?(name) || built?(name) ||
29
+ derived?(name) || rescued?(name)
30
+ end
31
+
32
+ def reaches
33
+ tests { true }.reject { @ownership.owned?(it) }.map(&:first).uniq
34
+ end
35
+
36
+ private
37
+
38
+ def convert?
39
+ @tail.is_a?(Prism::CallNode) && @tail.name == :new &&
40
+ Hashira::Analysis::Syntax.segments(@tail.receiver).any? && stateless?
41
+ end
42
+
43
+ def stateless? = @body.none? { STATE.include?(it.class) }
44
+
45
+ def fenced?(name)
46
+ tested = tests { it == name }
47
+ tested.any? && tested.none? { @ownership.owned?(it) }
48
+ end
49
+
50
+ def wire?(name)
51
+ calls = @body.grep(Prism::CallNode).select { |call| local?(call.receiver) { it == name } }
52
+ calls.any? && calls.all? { keyed?(it) } && reassignments(name).none?
53
+ end
54
+
55
+ def built?(name)
56
+ writes(name).any? { LITERALS.include?(it.value.class) }
57
+ end
58
+
59
+ def derived?(name)
60
+ writes(name).any? { spawned?(it.value) }
61
+ end
62
+
63
+ def rescued?(name)
64
+ snares(name).any? { alien?(it.exceptions) }
65
+ end
66
+
67
+ def keyed?(call)
68
+ names = call.arguments&.arguments
69
+ KEYED_READS.include?(call.name) && names&.any? &&
70
+ names.all? { KEYS.include?(it.class) }
71
+ end
72
+
73
+ def writes(name) = among(Prism::LocalVariableWriteNode, name)
74
+
75
+ def reassignments(name) = among(Prism::LocalVariableOperatorWriteNode, name)
76
+
77
+ def among(kind, name)
78
+ @body.grep(kind).select { it.name == name }
79
+ end
80
+
81
+ def snares(name)
82
+ @body.grep(Prism::RescueNode).select { it.reference&.name == name }
83
+ end
84
+
85
+ def alien?(exceptions)
86
+ exceptions.map { Hashira::Analysis::Syntax.segments(it) }.none? { @ownership.owned?(it) }
87
+ end
88
+
89
+ def spawned?(value)
90
+ value.is_a?(Prism::CallNode) && stranger?(value.receiver)
91
+ end
92
+
93
+ def stranger?(node)
94
+ case node
95
+ when Prism::LocalVariableReadNode then fenced?(node.name)
96
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then unowned?(node)
97
+ else false
98
+ end
99
+ end
100
+
101
+ def unowned?(node)
102
+ !@ownership.owned?(Hashira::Analysis::Syntax.segments(node))
103
+ end
104
+
105
+ def tests(&)
106
+ (probes(&) + arms(&)).map { Hashira::Analysis::Syntax.segments(it) }.reject(&:empty?) + lookups(&)
107
+ end
108
+
109
+ def probes(&)
110
+ @body.grep(Prism::CallNode).select { TYPE_TESTS.include?(it.name) && local?(it.receiver, &) }.filter_map { key(it) }
111
+ end
112
+
113
+ def arms(&)
114
+ @body.grep(Prism::CaseNode).select { local?(it.predicate, &) }.flat_map(&:conditions).flat_map(&:conditions)
115
+ end
116
+
117
+ def lookups(&)
118
+ @body.grep(Prism::CallNode).select { it.name == :[] && sorts?(key(it), &) }.flat_map { @ownership.keys(Hashira::Analysis::Syntax.segments(it.receiver)) }
119
+ end
120
+
121
+ def sorts?(argument, &)
122
+ argument.is_a?(Prism::CallNode) && argument.name == :class &&
123
+ local?(argument.receiver, &)
124
+ end
125
+
126
+ def local?(node, &) = node.is_a?(Prism::LocalVariableReadNode) && yield(node.name)
127
+
128
+ def key(call) = call.arguments&.arguments&.first
129
+
130
+ def tail(def_node) = Hashira::Analysis::Syntax.statements(def_node).compact.last
131
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Smells::Ownership
4
+ def initialize(trees)
5
+ @suffixes = Set.new
6
+ @tables = {}
7
+ trees.each { survey(it) }
8
+ end
9
+
10
+ def owned?(segments) = @suffixes.include?(segments.join("::"))
11
+
12
+ def keys(segments) = @tables.fetch(segments.join("::"), [])
13
+
14
+ private
15
+
16
+ def survey(tree)
17
+ Hashira::Analysis::TypeWalk.each(tree) do |node, full|
18
+ absorb(full)
19
+ Hashira::Analysis::Syntax.constants(node).each { record(full, it) }
20
+ end
21
+ end
22
+
23
+ def record(full, constant)
24
+ path = full + [constant.name.to_s]
25
+ absorb(path)
26
+ chart(path, thaw(constant.value))
27
+ end
28
+
29
+ def absorb(path)
30
+ @suffixes.merge(suffixes(path))
31
+ end
32
+
33
+ def chart(path, value)
34
+ return unless value.is_a?(Prism::HashNode)
35
+ keys = value.elements.map { spine(it) }
36
+ return if keys.empty? || keys.any?(&:nil?)
37
+ suffixes(path).each { @tables[it] = keys }
38
+ end
39
+
40
+ def thaw(value)
41
+ frozen?(value) ? value.receiver : value
42
+ end
43
+
44
+ def frozen?(value) = value.is_a?(Prism::CallNode) && value.name == :freeze
45
+
46
+ def spine(element)
47
+ return unless element.is_a?(Prism::AssocNode)
48
+ segments = Hashira::Analysis::Syntax.segments(element.key)
49
+ segments unless segments.empty?
50
+ end
51
+
52
+ def suffixes(path)
53
+ path.each_index.map { path.drop(it).join("::") }
54
+ end
55
+ end
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "boundary_sprawl"
3
4
  require_relative "check"
4
5
  require_relative "control_parameter"
5
6
  require_relative "data_clump"
6
7
  require_relative "duplicate_method_call"
7
8
  require_relative "feature_envy"
9
+ require_relative "foreign"
8
10
  require_relative "instance_variable_assumption"
9
11
  require_relative "manual_dispatch"
10
12
  require_relative "module_initialize"
11
13
  require_relative "nil_check"
14
+ require_relative "ownership"
12
15
  require_relative "repeated_conditional"
13
16
  require_relative "too_many_instance_variables"
14
17
  require_relative "utility_function"
@@ -21,13 +24,18 @@ class Hashira::Smells::Report
21
24
  PROBES = CHECKS.reject(&:judge?).freeze
22
25
 
23
26
  def initialize(project, trees)
24
- @types = Hashira::Smells::Census.new(project, trees).types
27
+ @census = Hashira::Smells::Census.new(project, trees)
28
+ @types = @census.types
25
29
  end
26
30
 
27
- def findings = @findings ||= sniff(@types, JUDGES) + sniff(@types.flat_map(&:defs), PROBES)
31
+ def findings = @findings ||= sniff(@types, JUDGES) + sniff(methods, PROBES) + sprawl
28
32
 
29
33
  private
30
34
 
35
+ def methods = @types.flat_map(&:defs)
36
+
37
+ def sprawl = Hashira::Smells::BoundarySprawl.new(methods, @census.ownership).findings
38
+
31
39
  def sniff(subjects, checks) = subjects.flat_map { |subject| verdicts(subject, checks) }
32
40
 
33
41
  def verdicts(subject, checks) = checks.filter_map { |check| check.new(subject).finding }
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Trees
6
+ def initialize(project)
7
+ @unparsed = []
8
+ @all = project.files.to_h { [it, parse(it)] }
9
+ end
10
+
11
+ attr_reader :all, :unparsed
12
+
13
+ private
14
+
15
+ def parse(path)
16
+ result = Prism.parse_file(path)
17
+ @unparsed << path if result.failure?
18
+ result.value
19
+ rescue SystemCallError => error
20
+ raise(Hashira::Error, "cannot read #{path} (#{error.message})")
21
+ end
22
+ end