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,44 +1,50 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- class CLI
5
- class Run
6
- MODES = { update_baseline: :update_baseline, ratchet: :check_ratchet,
7
- fail_on: :check_gate, json: :print_json,
8
- dot: :print_diagram, mermaid: :print_diagram }.freeze
3
+ class Hashira::CLI::Run
4
+ MODES = { update: :update, ratchet: :check, fail_on: :guard, json: :json, dot: :diagram, mermaid: :diagram }.freeze
9
5
 
10
- def initialize(pipeline, options)
11
- @pipeline = pipeline
12
- @options = options
13
- end
6
+ def initialize(pipeline, options)
7
+ @pipeline = pipeline
8
+ @options = options
9
+ end
10
+
11
+ def status = __send__(MODES.fetch(@options.mode, :text))
12
+
13
+ private
14
14
 
15
- def exit_code = send(MODES.fetch(@options.mode, :print_text))
15
+ def graph = @pipeline.graph
16
16
 
17
- private
17
+ def findings = @findings ||= Hashira::CI::Accepted.load(@options.baseline).screen(@pipeline.findings)
18
18
 
19
- def graph = @pipeline.graph
19
+ def update = ratchet.update
20
+
21
+ def check
22
+ stop = ratchet.blocker
23
+ raise(Hashira::Error, stop) if stop
24
+ ratchet.check
25
+ end
20
26
 
21
- def ratchet = CI::Ratchet.new(graph, findings.all, @options.baseline)
27
+ def guard = gate.check
22
28
 
23
- def update_baseline = ratchet.update
29
+ def diagram = source.print
24
30
 
25
- def check_ratchet = ratchet.check
31
+ def json = report(Hashira::Report::Json)
26
32
 
27
- def findings = @findings ||= CI::Accepted.load(@options.baseline).screen(@pipeline.findings)
33
+ def text = report(Hashira::Report::Text)
28
34
 
29
- def check_gate = CI::Gate.new(findings, @options.fail_on).check
35
+ def report(kind) = kind.new(view).print
30
36
 
31
- def print_json = Report::Json.new(view).print
37
+ def ratchet = @ratchet ||= Hashira::CI::Ratchet.new(graph, findings.all, @options.baseline)
32
38
 
33
- def print_diagram = Diagram::Renderer.new(graph, @options.mode).display
39
+ def gate = Hashira::CI::Gate.new(findings, @options.fail_on)
34
40
 
35
- def print_text = Report::Text.new(view).print
41
+ def source = Hashira::Diagram::Source.new(graph, @options.mode)
36
42
 
37
- def view
38
- Report::View.new(project: @pipeline.project, graph: (graph if @pipeline.enabled?(:coupling)),
39
- complexity: @pipeline.complexity, duplication: @pipeline.duplication,
40
- hotspots: @pipeline.hotspots, findings:)
41
- end
42
- end
43
+ def view
44
+ Hashira::Report::View.new(
45
+ project: @pipeline.project, graph: (graph if @pipeline.enabled?(:coupling)),
46
+ complexity: @pipeline.complexity, duplication: @pipeline.duplication,
47
+ hotspots: @pipeline.hotspots, findings:
48
+ )
43
49
  end
44
50
  end
@@ -1,27 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- class CLI
5
- module Skip
6
- module_function
3
+ module Hashira::CLI::Skip
4
+ module_function
7
5
 
8
- def parse(list)
9
- return [] unless list
10
-
11
- keys = list.split(",").map { key(it.strip) }
12
- raise Error, "cannot skip every analyzer" if (Pipeline::ANALYZERS - keys).empty?
13
-
14
- keys
15
- end
6
+ def parse(list)
7
+ return [] unless list
8
+ keys = list.split(",").map { key(it.strip) }
9
+ raise(Hashira::Error, "cannot skip every analyzer") if (Hashira::Pipeline::ANALYZERS - keys).empty?
10
+ keys
11
+ end
16
12
 
17
- def key(name)
18
- symbol = name.to_sym
19
- Pipeline::ANALYZERS.include?(symbol) ? symbol : unknown(name)
20
- end
13
+ def key(name)
14
+ symbol = name.to_sym
15
+ Hashira::Pipeline::ANALYZERS.include?(symbol) ? symbol : unknown(name)
16
+ end
21
17
 
22
- def unknown(name)
23
- raise Error, "unknown --skip #{name.inspect} (use: #{Pipeline::ANALYZERS.join(", ")})"
24
- end
25
- end
18
+ def unknown(name)
19
+ raise(Hashira::Error, "unknown --skip #{name.inspect} (use: #{Hashira::Pipeline::ANALYZERS.join(", ")})")
26
20
  end
27
21
  end
@@ -1,45 +1,48 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- class CLI
5
- module Usage
6
- TEXT = <<~HELP
7
- Usage: hashira [DIRECTORY ...] [options]
8
-
9
- Coupling, cognitive-complexity, and duplication metrics for Ruby, via
10
- Prism, rolled up per file as a ranked list of hotspots. With no
11
- directory, auto-detects lib/<gem>.
12
-
13
- Options:
14
- --format FORMAT text (default), json, dot, or mermaid
15
- --json shorthand for --format json
16
- --fail-on KINDS exit 1 if findings exist; comma-separated
17
- kinds: cycles, sdp, complexity, duplication
18
- --skip ANALYZERS drop an analyzer; comma-separated: coupling, complexity, duplication
19
- --ratchet fail when edges or findings appear that the
20
- baseline lacks
21
- --update-baseline record the current edges and findings
22
- --baseline PATH baseline file (default: hashira_baseline.json)
23
- -h, --help print this help
24
- --version print the version
25
-
26
- Findings accepted by design can be recorded in the baseline as
27
- "accepted": [{"kind": "...", "package": "...", "reason": "..."}]
28
- — they leave reports and gates, keeping a one-line reminder each.
29
- Clones are named by "digest" instead of "package", so an acceptance
30
- survives the lines above it moving. Read digests from --json.
31
- HELP
32
-
33
- module_function
34
-
35
- def help = emit(TEXT)
36
-
37
- def version = emit("hashira #{VERSION}")
38
-
39
- def emit(output)
40
- puts output
41
- 0
42
- end
43
- end
3
+ module Hashira::CLI::Usage
4
+ TEXT = <<~HELP
5
+ Usage: hashira [DIRECTORY ...] [options]
6
+
7
+ Coupling, cognitive-complexity, duplication, and code-smell metrics
8
+ for Ruby, via Prism, rolled up per file as a ranked list of hotspots.
9
+ With no directory, auto-detects lib/<gem>.
10
+
11
+ Options:
12
+ --format FORMAT text (default), json, dot, or mermaid
13
+ --json shorthand for --format json
14
+ --fail-on KINDS exit 1 if findings exist; comma-separated
15
+ kinds: cycles, sdp, mixed_audience, wide_edge,
16
+ roll_call, complexity, duplication, smells (all
17
+ of them), or one smell kind such as feature_envy
18
+ --skip ANALYZERS drop an analyzer; comma-separated: coupling,
19
+ complexity, duplication, smells
20
+ --package-by WHAT group coupling by: auto, folder, or namespace
21
+ (top-level constant). Default auto: namespace for
22
+ Rails apps (config/application.rb in or beside the
23
+ analyzed directory), folder otherwise
24
+ --ratchet fail when edges or findings appear that the
25
+ baseline lacks
26
+ --update-baseline record the current edges and findings
27
+ --baseline PATH baseline file (default: hashira_baseline.json)
28
+ -h, --help print this help
29
+ --version print the version
30
+
31
+ Findings accepted by design can be recorded in the baseline as
32
+ "accepted": [{"kind": "...", "package": "...", "reason": "..."}]
33
+ — they leave reports and gates, keeping a one-line reminder each.
34
+ Clones are named by "digest" instead of "package", so an acceptance
35
+ survives the lines above it moving. Read digests from --json.
36
+ HELP
37
+
38
+ module_function
39
+
40
+ def help = emit(TEXT)
41
+
42
+ def version = emit("hashira #{Hashira::VERSION}")
43
+
44
+ def emit(output)
45
+ puts(output)
46
+ 0
44
47
  end
45
48
  end
data/lib/hashira/cli.rb CHANGED
@@ -1,26 +1,24 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- class CLI
5
- def self.run(argv)
6
- options = Options.parse(argv)
7
- usage?(options) ? Usage.public_send(options.mode) : new(options).run
8
- rescue Error => error
9
- report_failure(error)
10
- end
11
-
12
- def self.usage?(options) = %i[help version].include?(options.mode)
3
+ class Hashira::CLI
4
+ def self.run(argv)
5
+ options = Options.parse(argv)
6
+ usage?(options) ? Usage.public_send(options.mode) : new(options).run
7
+ rescue Hashira::Error => error
8
+ failure(error)
9
+ end
13
10
 
14
- def self.report_failure(error)
15
- warn "hashira: #{error.message}"
16
- 1
17
- end
11
+ def self.usage?(options) = %i[help version].include?(options.mode)
18
12
 
19
- def initialize(options)
20
- @options = options
21
- @pipeline = Pipeline.new(Project.detect(options.directories), enabled: Pipeline::ANALYZERS - options.skip)
22
- end
13
+ def self.failure(error)
14
+ warn("hashira: #{error.message}")
15
+ 1
16
+ end
23
17
 
24
- def run = Run.new(@pipeline, @options).exit_code
18
+ def initialize(options)
19
+ @options = options
20
+ @pipeline = options.pipeline
25
21
  end
22
+
23
+ def run = Run.new(@pipeline, @options).status
26
24
  end
@@ -1,22 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Complexity
5
- class BooleanRun
6
- def initialize(scorer)
7
- @scorer = scorer
8
- end
3
+ class Hashira::Complexity::BooleanRun
4
+ def initialize(scorer)
5
+ @scorer = scorer
6
+ end
9
7
 
10
- def apply(node, nesting)
11
- @scorer.add(node, 1, "boolean")
12
- operands(node).each { @scorer.visit(it, nesting) }
13
- end
8
+ def apply(node)
9
+ @scorer.add(node, 1, "boolean")
10
+ operands(node).each { @scorer.visit(it) }
11
+ end
14
12
 
15
- private
13
+ private
16
14
 
17
- def operands(node)
18
- node.compact_child_nodes.flat_map { it.instance_of?(node.class) ? operands(it) : [it] }
19
- end
20
- end
15
+ def operands(node)
16
+ node.compact_child_nodes.flat_map { kin?(it, node) ? operands(it) : [it] }
21
17
  end
18
+
19
+ def kin?(child, node) = child.instance_of?(node.class)
22
20
  end
@@ -2,71 +2,73 @@
2
2
 
3
3
  require "prism"
4
4
 
5
- module Hashira
6
- module Complexity
7
- class CognitiveScore
8
- HANDLERS = {
9
- Prism::IfNode => :on_if,
10
- Prism::UnlessNode => :on_nester,
11
- Prism::WhileNode => :on_nester,
12
- Prism::UntilNode => :on_nester,
13
- Prism::ForNode => :on_nester,
14
- Prism::CaseNode => :on_nester,
15
- Prism::CaseMatchNode => :on_nester,
16
- Prism::BeginNode => :on_begin,
17
- Prism::AndNode => :on_boolean,
18
- Prism::OrNode => :on_boolean,
19
- Prism::BlockNode => :on_block,
20
- Prism::CallNode => :on_call
21
- }.freeze
22
-
23
- LABELS = {
24
- Prism::UnlessNode => "unless", Prism::WhileNode => "while",
25
- Prism::UntilNode => "until", Prism::ForNode => "for",
26
- Prism::CaseNode => "case", Prism::CaseMatchNode => "case"
27
- }.freeze
28
-
29
- def initialize(def_node)
30
- @increments = []
31
- @calls = 0
32
- visit(def_node.body, 0)
33
- end
34
-
35
- attr_reader :increments, :calls
36
-
37
- def total = @increments.sum(&:cost)
38
-
39
- def visit(node, nesting)
40
- return unless node
41
-
42
- send(HANDLERS.fetch(node.class, :descend), node, nesting)
43
- end
44
-
45
- def add(node, cost, label)
46
- @increments << Increment.new(line: node.location.start_line, cost:, label:)
47
- end
48
-
49
- private
50
-
51
- def descend(node, nesting) = node.compact_child_nodes.each { visit(it, nesting) }
52
-
53
- def on_call(node, nesting)
54
- @calls += 1
55
- descend(node, nesting)
56
- end
57
-
58
- def on_block(node, nesting) = node.compact_child_nodes.each { visit(it, nesting + 1) }
59
-
60
- def on_nester(node, nesting)
61
- add(node, 1 + nesting, LABELS.fetch(node.class))
62
- node.compact_child_nodes.each { visit(it, nesting + 1) }
63
- end
64
-
65
- def on_if(node, nesting) = IfChain.new(self).apply(node, nesting)
66
-
67
- def on_begin(node, nesting) = RescueScan.new(self).apply(node, nesting)
68
-
69
- def on_boolean(node, nesting) = BooleanRun.new(self).apply(node, nesting)
70
- end
5
+ class Hashira::Complexity::CognitiveScore
6
+ HANDLERS = {
7
+ Prism::IfNode => :on_if,
8
+ Prism::UnlessNode => :on_nester,
9
+ Prism::WhileNode => :on_nester,
10
+ Prism::UntilNode => :on_nester,
11
+ Prism::ForNode => :on_nester,
12
+ Prism::CaseNode => :on_nester,
13
+ Prism::CaseMatchNode => :on_nester,
14
+ Prism::BeginNode => :on_begin,
15
+ Prism::AndNode => :on_boolean,
16
+ Prism::OrNode => :on_boolean,
17
+ Prism::BlockNode => :on_block,
18
+ Prism::CallNode => :on_call
19
+ }.freeze
20
+
21
+ LABELS = {
22
+ Prism::UnlessNode => "unless", Prism::WhileNode => "while",
23
+ Prism::UntilNode => "until", Prism::ForNode => "for",
24
+ Prism::CaseNode => "case", Prism::CaseMatchNode => "case"
25
+ }.freeze
26
+
27
+ def initialize(def_node)
28
+ @increments = []
29
+ @calls = 0
30
+ @nesting = 0
31
+ visit(def_node.body)
71
32
  end
33
+
34
+ attr_reader :increments, :calls, :nesting
35
+
36
+ def total = @increments.sum(&:cost)
37
+
38
+ def visit(node)
39
+ return unless node
40
+ __send__(HANDLERS.fetch(node.class, :descend), node)
41
+ end
42
+
43
+ def add(node, cost, label)
44
+ @increments << Hashira::Complexity::Increment.new(line: node.location.start_line, cost:, label:)
45
+ end
46
+
47
+ def deeper
48
+ @nesting += 1
49
+ yield
50
+ @nesting -= 1
51
+ end
52
+
53
+ private
54
+
55
+ def descend(node) = node.compact_child_nodes.each { visit(it) }
56
+
57
+ def on_call(node)
58
+ @calls += 1
59
+ descend(node)
60
+ end
61
+
62
+ def on_block(node) = deeper { descend(node) }
63
+
64
+ def on_nester(node)
65
+ add(node, 1 + @nesting, LABELS.fetch(node.class))
66
+ deeper { descend(node) }
67
+ end
68
+
69
+ def on_if(node) = Hashira::Complexity::IfChain.new(self).apply(node)
70
+
71
+ def on_begin(node) = Hashira::Complexity::RescueScan.new(self).apply(node)
72
+
73
+ def on_boolean(node) = Hashira::Complexity::BooleanRun.new(self).apply(node)
72
74
  end
@@ -2,44 +2,39 @@
2
2
 
3
3
  require "prism"
4
4
 
5
- module Hashira
6
- module Complexity
7
- class IfChain
8
- def initialize(scorer)
9
- @scorer = scorer
10
- end
11
-
12
- def apply(node, nesting)
13
- return ternary(node, nesting) unless node.if_keyword
14
-
15
- branch(node, 1 + nesting, nesting, "if")
16
- end
17
-
18
- private
19
-
20
- def branch(node, cost, nesting, label)
21
- @scorer.add(node, cost, label)
22
- @scorer.visit(node.predicate, nesting)
23
- @scorer.visit(node.statements, nesting + 1)
24
- tail(node.subsequent, nesting)
25
- end
26
-
27
- def tail(node, nesting)
28
- case node
29
- when Prism::IfNode then branch(node, 1, nesting, "elsif")
30
- when Prism::ElseNode then otherwise(node, nesting)
31
- end
32
- end
33
-
34
- def otherwise(node, nesting)
35
- @scorer.add(node, 1, "else")
36
- @scorer.visit(node.statements, nesting + 1)
37
- end
38
-
39
- def ternary(node, nesting)
40
- @scorer.add(node, 1, "ternary")
41
- node.compact_child_nodes.each { @scorer.visit(it, nesting) }
42
- end
5
+ class Hashira::Complexity::IfChain
6
+ def initialize(scorer)
7
+ @scorer = scorer
8
+ end
9
+
10
+ def apply(node)
11
+ return ternary(node) unless node.if_keyword
12
+ branch(node, 1 + @scorer.nesting, "if")
13
+ end
14
+
15
+ private
16
+
17
+ def branch(node, cost, label)
18
+ @scorer.add(node, cost, label)
19
+ @scorer.visit(node.predicate)
20
+ @scorer.deeper { @scorer.visit(node.statements) }
21
+ tail(node.subsequent)
22
+ end
23
+
24
+ def tail(node)
25
+ case node
26
+ when Prism::IfNode then branch(node, 1, "elsif")
27
+ when Prism::ElseNode then otherwise(node)
43
28
  end
44
29
  end
30
+
31
+ def otherwise(node)
32
+ @scorer.add(node, 1, "else")
33
+ @scorer.deeper { @scorer.visit(node.statements) }
34
+ end
35
+
36
+ def ternary(node)
37
+ @scorer.add(node, 1, "ternary")
38
+ node.compact_child_nodes.each { @scorer.visit(it) }
39
+ end
45
40
  end
@@ -1,52 +1,30 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Complexity
5
- class MethodFinding
6
- ADVICE = {
7
- "if" => "flatten the branching — guard clauses, early returns, or polymorphism.",
8
- "elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch.",
9
- "else" => "flatten the branching — guard clauses, early returns, or polymorphism.",
10
- "case" => "a case this size often wants polymorphism or a dispatch table.",
11
- "boolean" => "name the compound condition in a predicate method.",
12
- "rescue" => "narrow the rescue, or lift error handling to the caller.",
13
- "while" => "extract the loop body into its own method.",
14
- "until" => "extract the loop body into its own method.",
15
- "for" => "extract the loop body into its own method.",
16
- "unless" => "invert to a guard clause or a named predicate.",
17
- "ternary" => "extract the nested ternary into a named method."
18
- }.freeze
19
-
20
- def initialize(score)
21
- @score = score
22
- end
23
-
24
- def to_finding
25
- Analysis::Finding.new(kind: "complexity", package: @score.subject, cycle: nil,
26
- message:, evidence:)
27
- end
3
+ class Hashira::Complexity::MethodFinding
4
+ def initialize(score)
5
+ @score = score
6
+ end
28
7
 
29
- private
8
+ def to_finding
9
+ Hashira::Analysis::Finding.new(kind: "complexity", package: @score.subject, detail:, evidence:)
10
+ end
30
11
 
31
- def message
32
- "#{@score.subject} — cognitive #{@score.cognitive}, #{@score.calls} calls " \
33
- "(#{@score.file}:#{@score.line}). #{advice}"
34
- end
12
+ private
35
13
 
36
- def evidence
37
- @score.increments.group_by(&:label).map { |label, incs| line_summary(label, incs) }
38
- end
14
+ def detail
15
+ { cognitive: @score.cognitive, calls: @score.calls, site: "#{@score.file}:#{@score.line}", dominant: }
16
+ end
39
17
 
40
- def line_summary(label, incs)
41
- lines = incs.map(&:line).uniq
42
- "#{label} +#{incs.sum(&:cost)} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
43
- end
18
+ def evidence
19
+ @score.increments.group_by(&:label).map { |label, incs| lines(label, incs) }
20
+ end
44
21
 
45
- def advice = ADVICE.fetch(dominant)
22
+ def lines(label, incs)
23
+ lines = incs.map(&:line).uniq
24
+ "#{label} +#{incs.sum(&:cost)} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
25
+ end
46
26
 
47
- def dominant
48
- @score.increments.group_by(&:label).transform_values { it.sum(&:cost) }.max_by(&:last).first
49
- end
50
- end
27
+ def dominant
28
+ @score.increments.group_by(&:label).transform_values { it.sum(&:cost) }.max_by(&:last).first
51
29
  end
52
30
  end
@@ -4,14 +4,16 @@ module Hashira
4
4
  module Complexity
5
5
  Increment = Data.define(:line, :cost, :label)
6
6
 
7
- MethodScore = Data.define(:subject, :file, :line, :cognitive, :calls, :increments) do
8
- def to_h = { subject:, file:, line:, cognitive:, calls: }
7
+ MethodScore =
8
+ Data.define(:subject, :file, :line, :cognitive, :calls, :increments) do
9
+ def to_h = { subject:, file:, line:, cognitive:, calls: }
9
10
 
10
- def cells = [subject, cognitive, calls, "#{file}:#{line}"]
11
- end
11
+ def cells = [subject, cognitive, calls, "#{file}:#{line}"]
12
+ end
12
13
 
13
- ClassScore = Data.define(:name, :cognitive, :method_count, :peak) do
14
- def cells = [name, cognitive, method_count, peak]
15
- end
14
+ ClassScore =
15
+ Data.define(:name, :cognitive, :method_count, :peak) do
16
+ def cells = [name, cognitive, method_count, peak]
17
+ end
16
18
  end
17
19
  end
@@ -1,28 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- module Hashira
4
- module Complexity
5
- class RescueScan
6
- def initialize(scorer)
7
- @scorer = scorer
8
- end
9
-
10
- def apply(node, nesting)
11
- @scorer.visit(node.statements, nesting)
12
- clauses(node.rescue_clause, nesting)
13
- @scorer.visit(node.else_clause, nesting)
14
- @scorer.visit(node.ensure_clause, nesting)
15
- end
3
+ class Hashira::Complexity::RescueScan
4
+ def initialize(scorer)
5
+ @scorer = scorer
6
+ end
16
7
 
17
- private
8
+ def apply(node)
9
+ @scorer.visit(node.statements)
10
+ clauses(node.rescue_clause)
11
+ @scorer.visit(node.else_clause)
12
+ @scorer.visit(node.ensure_clause)
13
+ end
18
14
 
19
- def clauses(node, nesting)
20
- return unless node
15
+ private
21
16
 
22
- @scorer.add(node, 1 + nesting, "rescue")
23
- @scorer.visit(node.statements, nesting + 1)
24
- clauses(node.subsequent, nesting)
25
- end
26
- end
17
+ def clauses(node)
18
+ return unless node
19
+ @scorer.add(node, 1 + @scorer.nesting, "rescue")
20
+ @scorer.deeper { @scorer.visit(node.statements) }
21
+ clauses(node.subsequent)
27
22
  end
28
23
  end