hashira 0.1.0 → 0.2.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 (69) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +53 -0
  3. data/README.md +327 -32
  4. data/lib/hashira/analysis/census.rb +0 -2
  5. data/lib/hashira/analysis/cycle_findings.rb +1 -1
  6. data/lib/hashira/analysis/cycle_search.rb +1 -1
  7. data/lib/hashira/analysis/definitions.rb +1 -1
  8. data/lib/hashira/analysis/finding.rb +6 -2
  9. data/lib/hashira/analysis/graph.rb +3 -3
  10. data/lib/hashira/analysis/node_walk.rb +7 -1
  11. data/lib/hashira/analysis/references.rb +2 -2
  12. data/lib/hashira/analysis/sdp_check.rb +1 -1
  13. data/lib/hashira/analysis/type_walk.rb +1 -1
  14. data/lib/hashira/churn.rb +22 -0
  15. data/lib/hashira/ci/accepted.rb +11 -7
  16. data/lib/hashira/ci/baseline.rb +40 -0
  17. data/lib/hashira/ci/diff.rb +15 -0
  18. data/lib/hashira/ci/edge_diff_report.rb +4 -27
  19. data/lib/hashira/ci/finding_diff_report.rb +26 -0
  20. data/lib/hashira/ci/gate.rb +7 -5
  21. data/lib/hashira/ci/improvement.rb +19 -0
  22. data/lib/hashira/ci/ratchet.rb +16 -20
  23. data/lib/hashira/ci/ratchet_report.rb +42 -0
  24. data/lib/hashira/cli/command_line.rb +12 -6
  25. data/lib/hashira/cli/fail_on.rb +4 -2
  26. data/lib/hashira/cli/options.rb +1 -1
  27. data/lib/hashira/cli/run.rb +9 -3
  28. data/lib/hashira/cli/skip.rb +27 -0
  29. data/lib/hashira/cli/usage.rb +10 -5
  30. data/lib/hashira/cli.rb +1 -1
  31. data/lib/hashira/complexity/analyzer.rb +45 -0
  32. data/lib/hashira/complexity/boolean_run.rb +22 -0
  33. data/lib/hashira/complexity/cognitive_score.rb +72 -0
  34. data/lib/hashira/complexity/if_chain.rb +45 -0
  35. data/lib/hashira/complexity/method_finding.rb +52 -0
  36. data/lib/hashira/complexity/method_score.rb +17 -0
  37. data/lib/hashira/complexity/rescue_scan.rb +28 -0
  38. data/lib/hashira/complexity/rollup.rb +22 -0
  39. data/lib/hashira/duplication/analyzer.rb +21 -0
  40. data/lib/hashira/duplication/cluster.rb +24 -0
  41. data/lib/hashira/duplication/clusterer.rb +44 -0
  42. data/lib/hashira/duplication/delta.rb +39 -0
  43. data/lib/hashira/duplication/duplication_finding.rb +30 -0
  44. data/lib/hashira/duplication/extractor.rb +31 -0
  45. data/lib/hashira/duplication/fragment.rb +42 -0
  46. data/lib/hashira/duplication/grouping.rb +24 -0
  47. data/lib/hashira/duplication/index.rb +35 -0
  48. data/lib/hashira/duplication/maximal.rb +21 -0
  49. data/lib/hashira/duplication/near_miss.rb +33 -0
  50. data/lib/hashira/duplication/sequence.rb +34 -0
  51. data/lib/hashira/duplication/similarity.rb +50 -0
  52. data/lib/hashira/duplication/union_find.rb +21 -0
  53. data/lib/hashira/duplication/variance.rb +57 -0
  54. data/lib/hashira/hotspots/file_cost.rb +19 -0
  55. data/lib/hashira/hotspots/rollup.rb +33 -0
  56. data/lib/hashira/pipeline.rb +28 -6
  57. data/lib/hashira/project.rb +10 -7
  58. data/lib/hashira/report/complexity_table.rb +40 -0
  59. data/lib/hashira/report/dependency_map.rb +8 -5
  60. data/lib/hashira/report/finding_lines.rb +1 -1
  61. data/lib/hashira/report/graph_payload.rb +29 -0
  62. data/lib/hashira/report/hotspot_table.rb +41 -0
  63. data/lib/hashira/report/json.rb +24 -17
  64. data/lib/hashira/report/metrics_table.rb +1 -2
  65. data/lib/hashira/report/text.rb +24 -14
  66. data/lib/hashira/report/view.rb +7 -0
  67. data/lib/hashira/version.rb +1 -1
  68. data/lib/hashira.rb +36 -0
  69. metadata +44 -6
@@ -7,14 +7,18 @@ module Hashira
7
7
  class Accepted
8
8
  Screened = Data.define(:all, :accepted)
9
9
 
10
- Entry = Data.define(:kind, :package, :reason) do
11
- def self.from(hash) = new(kind: hash["kind"], package: hash["package"], reason: hash["reason"])
10
+ Entry = Data.define(:kind, :package, :digest, :reason) do
11
+ def self.from(hash)
12
+ new(kind: hash["kind"], package: hash["package"], digest: hash["digest"], reason: hash["reason"])
13
+ end
12
14
 
13
- def matches?(finding) = [kind, package] == [finding.kind, finding.package]
15
+ def matches?(finding) = kind == finding.kind && identity == finding.identity
16
+
17
+ def identity = digest || package
14
18
 
15
19
  def label = reason || "accepted (no reason recorded)"
16
20
 
17
- def to_h = { kind:, package:, reason: }.compact
21
+ def to_h = { kind:, package:, digest:, reason: }.compact
18
22
  end
19
23
 
20
24
  def self.load(path)
@@ -24,20 +28,20 @@ module Hashira
24
28
  end
25
29
 
26
30
  def initialize(entries)
27
- @entries = entries.map { Entry.from(_1) }
31
+ @entries = entries.map { Entry.from(it) }
28
32
  end
29
33
 
30
34
  def entries = @entries.map(&:to_h)
31
35
 
32
36
  def screen(findings)
33
- accepted, live = findings.map { [_1, reason_for(_1)] }.partition(&:last)
37
+ accepted, live = findings.map { [it, reason_for(it)] }.partition(&:last)
34
38
  Screened.new(all: live.map(&:first), accepted:)
35
39
  end
36
40
 
37
41
  private
38
42
 
39
43
  def reason_for(finding)
40
- @entries.find { _1.matches?(finding) }&.label
44
+ @entries.find { it.matches?(finding) }&.label
41
45
  end
42
46
  end
43
47
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "json"
4
+
5
+ module Hashira
6
+ module CI
7
+ class Baseline
8
+ SCHEMA_VERSION = 2
9
+
10
+ def self.load(path) = new(path, File.exist?(path) ? JSON.parse(File.read(path)) : {})
11
+
12
+ def initialize(path, recorded)
13
+ @path = path
14
+ @recorded = recorded
15
+ end
16
+
17
+ attr_reader :path
18
+
19
+ def exist? = File.exist?(@path)
20
+
21
+ def edges = @recorded.fetch("edges", [])
22
+
23
+ def findings = @recorded.fetch("findings", [])
24
+
25
+ def ratchets_findings? = @recorded.key?("findings")
26
+
27
+ def write(edges, findings)
28
+ File.write(@path, JSON.pretty_generate(payload(edges, findings)) << "\n")
29
+ end
30
+
31
+ private
32
+
33
+ def payload(edges, findings)
34
+ base = { version: SCHEMA_VERSION, edges:, findings: }
35
+ accepted = Accepted.new(@recorded.fetch("accepted", [])).entries
36
+ accepted.empty? ? base : base.merge(accepted:)
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module CI
5
+ Diff = Data.define(:added, :removed) do
6
+ def self.between(current, recorded)
7
+ new(added: current - recorded, removed: recorded - current)
8
+ end
9
+
10
+ def empty? = added.empty? && removed.empty?
11
+
12
+ def worse? = !added.empty?
13
+ end
14
+ end
15
+ end
@@ -8,39 +8,16 @@ module Hashira
8
8
  @io = io
9
9
  end
10
10
 
11
- def print(added, removed)
12
- return print_unchanged if added.empty? && removed.empty?
13
-
14
- print_added(added)
15
- print_removed(removed)
16
- 1
11
+ def print(diff)
12
+ diff.added.each { print_edge(it) }
13
+ Improvement.new("Edges removed", io: @io).print(diff.removed)
17
14
  end
18
15
 
19
16
  private
20
17
 
21
- def print_unchanged
22
- @io.puts "Ratchet OK: #{@graph.edge_list.size} edges, unchanged."
23
- 0
24
- end
25
-
26
- def print_added(added)
27
- return if added.empty?
28
-
29
- added.each { print_edge(_1) }
30
- @io.puts "\nRatchet FAILED. Either decouple, or if the new edge is a deliberate"
31
- @io.puts "design decision, update the baseline and say why in the commit."
32
- end
33
-
34
18
  def print_edge(edge)
35
19
  @io.puts "NEW EDGE #{edge} — introduced by:"
36
- @graph.evidence_for(edge.from, edge.to).to_a.sort.each { @io.puts " · #{_1}" }
37
- end
38
-
39
- def print_removed(removed)
40
- return if removed.empty?
41
-
42
- @io.puts "Edges removed (improvement!): #{removed.join(", ")}"
43
- @io.puts "Lock it in: hashira --update-baseline"
20
+ @graph.evidence_for(edge.from, edge.to).to_a.sort.each { @io.puts " · #{it}" }
44
21
  end
45
22
  end
46
23
  end
@@ -0,0 +1,26 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module CI
5
+ class FindingDiffReport
6
+ def initialize(findings, io: $stdout)
7
+ @findings = findings
8
+ @io = io
9
+ end
10
+
11
+ def print(diff)
12
+ introduced(diff.added).each { print_finding(it) }
13
+ Improvement.new("Findings resolved", io: @io).print(diff.removed)
14
+ end
15
+
16
+ private
17
+
18
+ def introduced(added) = added.filter_map { |signature| @findings.find { it.signature == signature } }
19
+
20
+ def print_finding(finding)
21
+ @io.puts "NEW FINDING:"
22
+ Report::FindingLines.new(finding, indent: " ", io: @io).print_with_overflow
23
+ end
24
+ end
25
+ end
26
+ end
@@ -10,16 +10,18 @@ module Hashira
10
10
  end
11
11
 
12
12
  def check
13
- offending = @findings.all.select { @kinds.include?(_1.kind) }
14
- return report_clean if offending.empty?
13
+ offending = @findings.all.select { @kinds.include?(it.kind) }
14
+ offending.empty? ? report_clean : report_failure(offending)
15
+ end
16
+
17
+ private
15
18
 
16
- offending.each { Report::FindingLines.new(_1, io: @io).print }
19
+ def report_failure(offending)
20
+ offending.each { Report::FindingLines.new(it, io: @io).print }
17
21
  @io.puts "\nGate FAILED: #{offending.size} finding(s) of kind #{@kinds.join(", ")}."
18
22
  1
19
23
  end
20
24
 
21
- private
22
-
23
25
  def report_clean
24
26
  @io.puts "Gate OK: no findings of kind #{@kinds.join(", ")}."
25
27
  0
@@ -0,0 +1,19 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module CI
5
+ class Improvement
6
+ def initialize(label, io: $stdout)
7
+ @label = label
8
+ @io = io
9
+ end
10
+
11
+ def print(removed)
12
+ return if removed.empty?
13
+
14
+ @io.puts "#{@label} (improvement!): #{removed.join(", ")}"
15
+ @io.puts "Lock it in: hashira --update-baseline"
16
+ end
17
+ end
18
+ end
19
+ end
@@ -1,43 +1,39 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- require "json"
4
-
5
3
  module Hashira
6
4
  module CI
7
5
  class Ratchet
8
- def initialize(graph, baseline_path, io: $stdout)
6
+ def initialize(graph, findings, baseline_path, io: $stdout)
9
7
  @graph = graph
10
- @baseline_path = baseline_path
8
+ @findings = findings
9
+ @baseline = Baseline.load(baseline_path)
11
10
  @io = io
12
11
  end
13
12
 
14
- SCHEMA_VERSION = 1
15
-
16
13
  def update
17
- edges = @graph.edge_list.map(&:to_s)
18
- File.write(@baseline_path, JSON.pretty_generate(payload(edges)) << "\n")
19
- @io.puts "Baseline updated: #{edges.size} edges."
14
+ @baseline.write(edge_signatures, finding_signatures)
15
+ @io.puts "Baseline updated: #{edge_signatures.size} edges, #{finding_signatures.size} findings."
20
16
  0
21
17
  end
22
18
 
23
19
  def check
24
- added, removed = diff(@graph.edge_list)
25
- EdgeDiffReport.new(@graph, io: @io).print(added, removed)
20
+ raise Error, "no baseline at #{@baseline.path} — run --update-baseline first" unless @baseline.exist?
21
+
22
+ RatchetReport.new(@graph, @findings, io: @io).print(edge_diff, finding_diff)
26
23
  end
27
24
 
28
25
  private
29
26
 
30
- def payload(edges)
31
- accepted = Accepted.load(File.exist?(@baseline_path) ? @baseline_path : nil).entries
32
- base = { version: SCHEMA_VERSION, edges: }
33
- accepted.empty? ? base : base.merge(accepted:)
34
- end
27
+ def edge_signatures = @graph.edge_list.map(&:to_s)
28
+
29
+ def finding_signatures = @findings.map(&:signature).uniq.sort
30
+
31
+ def edge_diff = Diff.new(added: new_edges, removed: @baseline.edges - edge_signatures)
35
32
 
36
- def diff(edges)
37
- raise Error, "no baseline at #{@baseline_path} — run --update-baseline first" unless File.exist?(@baseline_path)
33
+ def new_edges = @graph.edge_list.reject { @baseline.edges.include?(it.to_s) }
38
34
 
39
- baseline = JSON.parse(File.read(@baseline_path)).fetch("edges")
40
- [edges.reject { baseline.include?(_1.to_s) }, baseline - edges.map(&:to_s)]
35
+ def finding_diff
36
+ Diff.between(finding_signatures, @baseline.findings) if @baseline.ratchets_findings?
41
37
  end
42
38
  end
43
39
  end
@@ -0,0 +1,42 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module CI
5
+ class RatchetReport
6
+ def initialize(graph, findings, io: $stdout)
7
+ @graph = graph
8
+ @findings = findings
9
+ @io = io
10
+ end
11
+
12
+ def print(edges, findings)
13
+ return unchanged if quiet?(edges, findings)
14
+
15
+ details(edges, findings)
16
+ advice(edges, findings)
17
+ 1
18
+ end
19
+
20
+ private
21
+
22
+ def quiet?(*diffs) = diffs.compact.all?(&:empty?)
23
+
24
+ def unchanged
25
+ @io.puts "Ratchet OK: #{@graph.edge_list.size} edges, #{@findings.size} findings, unchanged."
26
+ 0
27
+ end
28
+
29
+ def details(edges, findings)
30
+ EdgeDiffReport.new(@graph, io: @io).print(edges)
31
+ FindingDiffReport.new(@findings, io: @io).print(findings) if findings
32
+ end
33
+
34
+ def advice(*diffs)
35
+ return unless diffs.compact.any?(&:worse?)
36
+
37
+ @io.puts "\nRatchet FAILED. Either fix what regressed, or — if it is deliberate —"
38
+ @io.puts "record the decision: update the baseline, or accept it with a reason."
39
+ end
40
+ end
41
+ end
42
+ end
@@ -23,7 +23,7 @@ module Hashira
23
23
  usage(:version) if delete("--version")
24
24
  end
25
25
 
26
- def usage(mode) = Options.new(directories: [], mode:, baseline: nil, fail_on: [])
26
+ def usage(mode) = Options.new(directories: [], mode:, baseline: nil, fail_on: [], skip: [])
27
27
 
28
28
  def parsed_options
29
29
  values = flag_values
@@ -33,15 +33,21 @@ module Hashira
33
33
  end
34
34
 
35
35
  def flag_values
36
- { fail_on: FailOn.parse(take_value("--fail-on")),
36
+ { skip: Skip.parse(take_value("--skip")),
37
+ fail_on: FailOn.parse(take_value("--fail-on")),
37
38
  baseline: take_value("--baseline") || DEFAULT_BASELINE }
38
39
  end
39
40
 
40
41
  def parse_mode(fail_on)
41
- requested = requested_modes(fail_on).uniq(&:last)
42
- raise Error, "conflicting options: #{requested.map(&:first).join(" and ")}" if requested.size > 1
42
+ case (requested = requested_modes(fail_on).uniq(&:last))
43
+ in [] then :text
44
+ in [[_flag, mode]] then mode
45
+ else conflict(requested)
46
+ end
47
+ end
43
48
 
44
- requested.dig(0, 1) || :text
49
+ def conflict(requested)
50
+ raise Error, "conflicting options: #{requested.map(&:first).join(" and ")}"
45
51
  end
46
52
 
47
53
  def requested_modes(fail_on)
@@ -78,7 +84,7 @@ module Hashira
78
84
  def delete(flag) = @arguments.delete(flag)
79
85
 
80
86
  def reject_unknown_flags
81
- stray = @arguments.find { _1.start_with?("-") }
87
+ stray = @arguments.find { it.start_with?("-") }
82
88
  raise Error, "unknown option #{stray}" if stray
83
89
  end
84
90
  end
@@ -5,7 +5,9 @@ module Hashira
5
5
  module FailOn
6
6
  KINDS = {
7
7
  "cycles" => "cycle", "cycle" => "cycle",
8
- "sdp" => "sdp_violation", "sdp_violation" => "sdp_violation"
8
+ "sdp" => "sdp_violation", "sdp_violation" => "sdp_violation",
9
+ "complexity" => "complexity",
10
+ "duplication" => "duplication", "dupe" => "duplication"
9
11
  }.freeze
10
12
 
11
13
  module_function
@@ -13,7 +15,7 @@ module Hashira
13
15
  def parse(list)
14
16
  return [] unless list
15
17
 
16
- list.split(",").map { kind(_1.strip) }.uniq
18
+ list.split(",").map { kind(it.strip) }.uniq
17
19
  end
18
20
 
19
21
  def kind(name)
@@ -2,7 +2,7 @@
2
2
 
3
3
  module Hashira
4
4
  class CLI
5
- Options = Data.define(:directories, :mode, :baseline, :fail_on) do
5
+ Options = Data.define(:directories, :mode, :baseline, :fail_on, :skip) do
6
6
  def self.parse(argv) = CommandLine.new(argv).options
7
7
  end
8
8
  end
@@ -18,7 +18,7 @@ module Hashira
18
18
 
19
19
  def graph = @pipeline.graph
20
20
 
21
- def ratchet = CI::Ratchet.new(graph, @options.baseline)
21
+ def ratchet = CI::Ratchet.new(graph, findings.all, @options.baseline)
22
22
 
23
23
  def update_baseline = ratchet.update
24
24
 
@@ -28,11 +28,17 @@ module Hashira
28
28
 
29
29
  def check_gate = CI::Gate.new(findings, @options.fail_on).check
30
30
 
31
- def print_json = Report::Json.new(graph, findings).print
31
+ def print_json = Report::Json.new(view).print
32
32
 
33
33
  def print_diagram = Diagram::Renderer.new(graph, @options.mode).display
34
34
 
35
- def print_text = Report::Text.new(@pipeline.project, graph, findings).print
35
+ def print_text = Report::Text.new(view).print
36
+
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
36
42
  end
37
43
  end
38
44
  end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ class CLI
5
+ module Skip
6
+ module_function
7
+
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
16
+
17
+ def key(name)
18
+ symbol = name.to_sym
19
+ Pipeline::ANALYZERS.include?(symbol) ? symbol : unknown(name)
20
+ end
21
+
22
+ def unknown(name)
23
+ raise Error, "unknown --skip #{name.inspect} (use: #{Pipeline::ANALYZERS.join(", ")})"
24
+ end
25
+ end
26
+ end
27
+ end
@@ -6,16 +6,19 @@ module Hashira
6
6
  TEXT = <<~HELP
7
7
  Usage: hashira [DIRECTORY ...] [options]
8
8
 
9
- Package coupling metrics for Ruby, via Prism. With no directory,
10
- auto-detects lib/<gem>.
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>.
11
12
 
12
13
  Options:
13
14
  --format FORMAT text (default), json, dot, or mermaid
14
15
  --json shorthand for --format json
15
16
  --fail-on KINDS exit 1 if findings exist; comma-separated
16
- kinds: cycles, sdp
17
- --ratchet fail when edges appear that the baseline lacks
18
- --update-baseline write the current edge set as the baseline
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
19
22
  --baseline PATH baseline file (default: hashira_baseline.json)
20
23
  -h, --help print this help
21
24
  --version print the version
@@ -23,6 +26,8 @@ module Hashira
23
26
  Findings accepted by design can be recorded in the baseline as
24
27
  "accepted": [{"kind": "...", "package": "...", "reason": "..."}]
25
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.
26
31
  HELP
27
32
 
28
33
  module_function
data/lib/hashira/cli.rb CHANGED
@@ -18,7 +18,7 @@ module Hashira
18
18
 
19
19
  def initialize(options)
20
20
  @options = options
21
- @pipeline = Pipeline.new(Project.detect(options.directories))
21
+ @pipeline = Pipeline.new(Project.detect(options.directories), enabled: Pipeline::ANALYZERS - options.skip)
22
22
  end
23
23
 
24
24
  def run = Run.new(@pipeline, @options).exit_code
@@ -0,0 +1,45 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Complexity
5
+ class Analyzer
6
+ THRESHOLD = 10
7
+
8
+ def initialize(project, trees)
9
+ @project = project
10
+ @scores = trees.flat_map { |path, tree| scores_for(path, tree) }
11
+ end
12
+
13
+ def methods = @scores.sort_by { -it.cognitive }
14
+
15
+ def classes = Rollup.new(@scores).classes.sort_by { -it.cognitive }
16
+
17
+ def findings = flagged.map { MethodFinding.new(it).to_finding }
18
+
19
+ private
20
+
21
+ def flagged = methods.select { it.cognitive >= THRESHOLD }
22
+
23
+ def scores_for(path, tree)
24
+ rel = @project.relative(path)
25
+ methods_in(tree).map { |full, node| build_score(rel, full, node) }
26
+ end
27
+
28
+ def methods_in(tree)
29
+ found = []
30
+ Analysis::TypeWalk.each_definition(tree) do |type_node, full|
31
+ Analysis::Syntax.direct_definitions(type_node).each { found << [full, it] }
32
+ end
33
+ found
34
+ end
35
+
36
+ def build_score(rel, full, node)
37
+ score = CognitiveScore.new(node)
38
+ MethodScore.new(subject: subject(full, node), file: rel, line: node.location.start_line,
39
+ cognitive: score.total, calls: score.calls, increments: score.increments)
40
+ end
41
+
42
+ def subject(full, node) = "#{full.join("::")}#{node.receiver ? "." : "#"}#{node.name}"
43
+ end
44
+ end
45
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Complexity
5
+ class BooleanRun
6
+ def initialize(scorer)
7
+ @scorer = scorer
8
+ end
9
+
10
+ def apply(node, nesting)
11
+ @scorer.add(node, 1, "boolean")
12
+ operands(node).each { @scorer.visit(it, nesting) }
13
+ end
14
+
15
+ private
16
+
17
+ def operands(node)
18
+ node.compact_child_nodes.flat_map { it.instance_of?(node.class) ? operands(it) : [it] }
19
+ end
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,72 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
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
71
+ end
72
+ end