hashira 0.3.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 (140) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +127 -25
  4. data/lib/hashira/analysis/finding.rb +7 -6
  5. data/lib/hashira/analysis/node_walk.rb +3 -3
  6. data/lib/hashira/analysis/syntax.rb +21 -6
  7. data/lib/hashira/analysis/type_walk.rb +7 -9
  8. data/lib/hashira/churn.rb +11 -13
  9. data/lib/hashira/ci/accepted.rb +27 -31
  10. data/lib/hashira/ci/baseline.rb +22 -24
  11. data/lib/hashira/ci/diff.rb +8 -7
  12. data/lib/hashira/ci/edge_diff_report.rb +13 -17
  13. data/lib/hashira/ci/finding_diff_report.rb +14 -18
  14. data/lib/hashira/ci/gate.rb +19 -23
  15. data/lib/hashira/ci/improvement.rb +9 -14
  16. data/lib/hashira/ci/ratchet.rb +32 -27
  17. data/lib/hashira/ci/ratchet_report.rb +26 -32
  18. data/lib/hashira/cli/arguments.rb +23 -0
  19. data/lib/hashira/cli/command_line.rb +55 -71
  20. data/lib/hashira/cli/fail_on.rb +24 -19
  21. data/lib/hashira/cli/options.rb +9 -4
  22. data/lib/hashira/cli/package_by.rb +14 -0
  23. data/lib/hashira/cli/run.rb +33 -27
  24. data/lib/hashira/cli/skip.rb +14 -20
  25. data/lib/hashira/cli/usage.rb +44 -41
  26. data/lib/hashira/cli.rb +17 -19
  27. data/lib/hashira/complexity/boolean_run.rb +13 -15
  28. data/lib/hashira/complexity/cognitive_score.rb +68 -66
  29. data/lib/hashira/complexity/if_chain.rb +33 -38
  30. data/lib/hashira/complexity/method_finding.rb +20 -42
  31. data/lib/hashira/complexity/method_score.rb +9 -7
  32. data/lib/hashira/complexity/rescue_scan.rb +16 -21
  33. data/lib/hashira/complexity/rollup.rb +13 -15
  34. data/lib/hashira/complexity/scores.rb +44 -0
  35. data/lib/hashira/coupling/audiences.rb +49 -0
  36. data/lib/hashira/coupling/catalog.rb +27 -0
  37. data/lib/hashira/coupling/census.rb +55 -0
  38. data/lib/hashira/coupling/constant_registry.rb +47 -0
  39. data/lib/hashira/coupling/cycle_findings.rb +31 -0
  40. data/lib/hashira/coupling/cycle_search.rb +37 -0
  41. data/lib/hashira/coupling/cycles.rb +14 -0
  42. data/lib/hashira/coupling/definition.rb +22 -0
  43. data/lib/hashira/coupling/definitions.rb +31 -0
  44. data/lib/hashira/coupling/edge.rb +10 -0
  45. data/lib/hashira/coupling/edge_map.rb +33 -0
  46. data/lib/hashira/coupling/folder_placement.rb +17 -0
  47. data/lib/hashira/coupling/folding.rb +56 -0
  48. data/lib/hashira/coupling/graph.rb +63 -0
  49. data/lib/hashira/coupling/metric.rb +15 -0
  50. data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
  51. data/lib/hashira/coupling/namespace_placement.rb +28 -0
  52. data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +11 -11
  53. data/lib/hashira/coupling/naming.rb +14 -0
  54. data/lib/hashira/coupling/no_folding.rb +13 -0
  55. data/lib/hashira/coupling/placement.rb +19 -0
  56. data/lib/hashira/coupling/references.rb +55 -0
  57. data/lib/hashira/coupling/report.rb +28 -0
  58. data/lib/hashira/coupling/roll_call.rb +45 -0
  59. data/lib/hashira/coupling/roll_call_findings.rb +30 -0
  60. data/lib/hashira/coupling/roster.rb +25 -0
  61. data/lib/hashira/coupling/rule.rb +18 -0
  62. data/lib/hashira/coupling/scope.rb +28 -0
  63. data/lib/hashira/coupling/sdp_check.rb +14 -0
  64. data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
  65. data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
  66. data/lib/hashira/coupling/words.rb +29 -0
  67. data/lib/hashira/diagram/dot.rb +8 -11
  68. data/lib/hashira/diagram/mermaid.rb +11 -16
  69. data/lib/hashira/diagram/source.rb +15 -0
  70. data/lib/hashira/duplication/clones.rb +17 -0
  71. data/lib/hashira/duplication/cluster.rb +14 -13
  72. data/lib/hashira/duplication/clusters.rb +46 -0
  73. data/lib/hashira/duplication/delta.rb +21 -30
  74. data/lib/hashira/duplication/duplication_finding.rb +17 -23
  75. data/lib/hashira/duplication/fragment.rb +19 -23
  76. data/lib/hashira/duplication/grouping.rb +12 -16
  77. data/lib/hashira/duplication/harvest.rb +27 -0
  78. data/lib/hashira/duplication/index.rb +20 -24
  79. data/lib/hashira/duplication/maximal.rb +11 -15
  80. data/lib/hashira/duplication/near_miss.rb +19 -22
  81. data/lib/hashira/duplication/sequence.rb +20 -22
  82. data/lib/hashira/duplication/similarity.rb +31 -34
  83. data/lib/hashira/duplication/union_find.rb +10 -14
  84. data/lib/hashira/duplication/variance.rb +35 -43
  85. data/lib/hashira/error.rb +4 -2
  86. data/lib/hashira/hotspots/file_cost.rb +8 -7
  87. data/lib/hashira/hotspots/rollup.rb +19 -21
  88. data/lib/hashira/pipeline.rb +41 -31
  89. data/lib/hashira/project.rb +50 -49
  90. data/lib/hashira/report/complexity_table.rb +27 -31
  91. data/lib/hashira/report/dependency_map.rb +17 -22
  92. data/lib/hashira/report/finding_lines.rb +15 -19
  93. data/lib/hashira/report/graph_payload.rb +14 -20
  94. data/lib/hashira/report/hotspot_table.rb +32 -37
  95. data/lib/hashira/report/json.rb +32 -32
  96. data/lib/hashira/report/metrics_table.rb +46 -33
  97. data/lib/hashira/report/phrases.rb +98 -0
  98. data/lib/hashira/report/smell_phrases.rb +68 -0
  99. data/lib/hashira/report/text.rb +59 -52
  100. data/lib/hashira/smells/census.rb +32 -0
  101. data/lib/hashira/smells/check.rb +30 -0
  102. data/lib/hashira/smells/conditions.rb +44 -0
  103. data/lib/hashira/smells/contexts.rb +64 -0
  104. data/lib/hashira/smells/control_parameter.rb +22 -0
  105. data/lib/hashira/smells/data_clump.rb +30 -0
  106. data/lib/hashira/smells/duplicate_method_call.rb +54 -0
  107. data/lib/hashira/smells/feature_envy.rb +17 -0
  108. data/lib/hashira/smells/instance_variable_assumption.rb +44 -0
  109. data/lib/hashira/smells/manual_dispatch.rb +16 -0
  110. data/lib/hashira/smells/module_initialize.rb +9 -0
  111. data/lib/hashira/smells/nil_check.rb +32 -0
  112. data/lib/hashira/smells/param_check.rb +51 -0
  113. data/lib/hashira/smells/refs.rb +53 -0
  114. data/lib/hashira/smells/repeated_conditional.rb +29 -0
  115. data/lib/hashira/smells/report.rb +31 -0
  116. data/lib/hashira/smells/scope.rb +29 -0
  117. data/lib/hashira/smells/too_many_instance_variables.rb +24 -0
  118. data/lib/hashira/smells/utility_function.rb +13 -0
  119. data/lib/hashira/smells/visibility.rb +64 -0
  120. data/lib/hashira/version.rb +1 -1
  121. data/lib/hashira.rb +131 -60
  122. metadata +62 -20
  123. data/lib/hashira/analysis/census.rb +0 -39
  124. data/lib/hashira/analysis/constant_registry.rb +0 -40
  125. data/lib/hashira/analysis/cycle_findings.rb +0 -33
  126. data/lib/hashira/analysis/cycle_search.rb +0 -44
  127. data/lib/hashira/analysis/definitions.rb +0 -27
  128. data/lib/hashira/analysis/edge.rb +0 -9
  129. data/lib/hashira/analysis/edge_map.rb +0 -36
  130. data/lib/hashira/analysis/graph.rb +0 -56
  131. data/lib/hashira/analysis/metric.rb +0 -14
  132. data/lib/hashira/analysis/references.rb +0 -39
  133. data/lib/hashira/analysis/rule.rb +0 -22
  134. data/lib/hashira/analysis/sdp_check.rb +0 -18
  135. data/lib/hashira/analysis/sdp_violation_findings.rb +0 -27
  136. data/lib/hashira/complexity/analyzer.rb +0 -45
  137. data/lib/hashira/diagram/renderer.rb +0 -20
  138. data/lib/hashira/duplication/analyzer.rb +0 -21
  139. data/lib/hashira/duplication/clusterer.rb +0 -50
  140. data/lib/hashira/duplication/extractor.rb +0 -31
@@ -1,37 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Report
5
- class MetricsTable
6
- def initialize(graph, io: $stdout)
7
- @graph = graph
8
- @io = io
9
- end
10
-
11
- def print
12
- heading
13
- by_instability.each { |package, metric| @io.puts row(package, metric) }
14
- legend
15
- end
16
-
17
- private
18
-
19
- def by_instability = @graph.metrics.sort_by { |_package, metric| metric.instability }
20
-
21
- def heading
22
- @io.puts format("%-12s %3s %3s %3s %5s %-3s", *%w[package TC Ca Ce I Cyc])
23
- @io.puts "-" * 40
24
- end
25
-
26
- def row(package, metric)
27
- cyclic = @graph.cyclic?(package) ? "YES" : "-"
28
- format("%-12s %3d %3d %3d %5.2f %-3s", package, *metric.to_h.values, cyclic)
29
- end
30
-
31
- def legend
32
- @io.puts "\nLegend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),"
33
- @io.puts " I=Ce/(Ce+Ca) instability (0=maximally stable, 1=maximally unstable)\n\n"
34
- end
35
- end
3
+ class Hashira::Report::MetricsTable
4
+ def initialize(graph, io: $stdout)
5
+ @graph = graph
6
+ @io = io
7
+ end
8
+
9
+ LIMIT = 25
10
+
11
+ def print
12
+ heading
13
+ kept.each { |package, metric| @io.puts(row(package, metric)) }
14
+ note unless spared.empty?
15
+ legend
16
+ end
17
+
18
+ private
19
+
20
+ def ranked = @graph.metrics.sort_by { |_package, metric| metric.instability }
21
+
22
+ def split = @split ||= (ranked.size > LIMIT ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
23
+
24
+ def kept = split.first
25
+
26
+ def spared = split.last
27
+
28
+ def leaf?(metric) = metric.types <= 1 && metric.efferent.zero? && metric.afferent <= 1
29
+
30
+ def note
31
+ @io.puts(" + #{spared.size} single-type leaf packages (TC ≤ 1, Ca ≤ 1, Ce = 0) — hidden for brevity")
32
+ end
33
+
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
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
45
+
46
+ def legend
47
+ @io.puts("\nLegend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),")
48
+ @io.puts(" I=Ce/(Ce+Ca) instability (0=maximally stable, 1=maximally unstable)\n\n")
36
49
  end
37
50
  end
@@ -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
@@ -1,68 +1,75 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Report
5
- class Text
6
- def initialize(view, io: $stdout)
7
- @view = view
8
- @io = io
9
- end
3
+ class Hashira::Report::Text
4
+ def initialize(view, io: $stdout)
5
+ @view = view
6
+ @io = io
7
+ end
10
8
 
11
- def print
12
- coupling_sections if @view.graph
13
- complexity_section if @view.complexity
14
- hotspot_section if @view.hotspots
15
- findings_section
16
- 0
17
- end
9
+ def print
10
+ coupling if @view.graph
11
+ complexity if @view.complexity
12
+ hotspots if @view.hotspots
13
+ findings
14
+ 0
15
+ end
18
16
 
19
- private
17
+ private
20
18
 
21
- def coupling_sections
22
- graph = @view.graph
23
- header(graph)
24
- MetricsTable.new(graph, io: @io).print
25
- DependencyMap.new(graph, io: @io).print
26
- end
19
+ def coupling
20
+ graph = @view.graph
21
+ header(graph)
22
+ Hashira::Report::MetricsTable.new(graph, io: @io).print
23
+ Hashira::Report::DependencyMap.new(graph, io: @io).print
24
+ folded(graph.folds)
25
+ end
26
+
27
+ def folded(folds)
28
+ return if folds.empty?
29
+ @io.puts("\nFolded (single-type classes joined to their base or domain):")
30
+ folds.each { @io.puts(" #{it[:from]} -> #{it[:to]} (#{it[:via]})") }
31
+ end
27
32
 
28
- def complexity_section = ComplexityTable.new(@view.complexity, io: @io).print
33
+ def complexity = Hashira::Report::ComplexityTable.new(@view.complexity, io: @io).print
29
34
 
30
- def hotspot_section = HotspotTable.new(@view.hotspots, io: @io).print
35
+ def hotspots = Hashira::Report::HotspotTable.new(@view.hotspots, io: @io).print
31
36
 
32
- def header(graph)
33
- project = @view.project
34
- packages = graph.packages.size
35
- @io.puts "Package (layer) metrics for #{project.label} " \
36
- "(#{packages} packages, #{project.files.size} files)\n\n"
37
- single_package_note if packages == 1
38
- end
37
+ def header(graph)
38
+ packages = graph.packages.size
39
+ @io.puts(banner(packages))
40
+ caveat if packages == 1
41
+ end
39
42
 
40
- def single_package_note
41
- @io.puts "Only one package found there are no boundaries to analyze. " \
42
- "Pass subdirectories to set them (e.g. hashira lib/gem/*/).\n\n"
43
- end
43
+ def banner(packages)
44
+ "Package (layer) metrics for #{@view.project.label} (#{packages} packages, #{total} files)\n\n"
45
+ end
44
46
 
45
- def findings_section
46
- all = @view.findings.all
47
- @io.puts "\nFindings (#{all.size}):"
48
- print_findings(all)
49
- accepted_section
50
- @io.puts "\n Full evidence + machine format: hashira --json" unless all.empty?
51
- end
47
+ def total = @view.project.files.size
52
48
 
53
- def print_findings(all)
54
- return @io.puts " none ✓ — structure is healthy" if all.empty?
49
+ def caveat
50
+ @io.puts(
51
+ "Only one package found — there are no boundaries to analyze. " \
52
+ "Pass subdirectories to set them (e.g. hashira lib/gem/*/).\n\n"
53
+ )
54
+ end
55
55
 
56
- all.each { FindingLines.new(it, indent: " ", io: @io).print_with_overflow }
57
- end
56
+ def findings
57
+ all = @view.findings.all
58
+ @io.puts("\nFindings (#{all.size}):")
59
+ list(all)
60
+ accepted
61
+ @io.puts("\n Full evidence + machine format: hashira --json") unless all.empty?
62
+ end
58
63
 
59
- def accepted_section
60
- accepted = @view.findings.accepted
61
- return if accepted.empty?
64
+ def list(all)
65
+ return @io.puts(" none ✓ — structure is healthy") if all.empty?
66
+ all.each { Hashira::Report::FindingLines.new(it, indent: " ", io: @io).emit }
67
+ end
62
68
 
63
- @io.puts "\nAccepted (#{accepted.size}):"
64
- accepted.each { |finding, reason| @io.puts " ~ #{finding.kind}/#{finding.package} — #{reason}" }
65
- end
66
- end
69
+ def accepted
70
+ accepted = @view.findings.accepted
71
+ return if accepted.empty?
72
+ @io.puts("\nAccepted (#{accepted.size}):")
73
+ accepted.each { |finding, reason| @io.puts(" ~ #{finding.kind}/#{finding.package} — #{reason}") }
67
74
  end
68
75
  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
@@ -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