hashira 0.7.0 → 0.9.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 (103) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +94 -0
  3. data/README.md +57 -3
  4. data/exe/hashira +1 -1
  5. data/lib/hashira/analysis/finding.rb +11 -1
  6. data/lib/hashira/analysis/syntax.rb +6 -6
  7. data/lib/hashira/analysis/type_walk.rb +4 -0
  8. data/lib/hashira/churn.rb +1 -10
  9. data/lib/hashira/ci/accepted.rb +9 -7
  10. data/lib/hashira/ci/baseline.rb +53 -33
  11. data/lib/hashira/ci/comparison.rb +33 -0
  12. data/lib/hashira/ci/diff.rb +0 -15
  13. data/lib/hashira/ci/gate.rb +3 -3
  14. data/lib/hashira/ci/mark.rb +7 -0
  15. data/lib/hashira/ci/ratchet.rb +12 -4
  16. data/lib/hashira/ci/ratchet_report.rb +4 -4
  17. data/lib/hashira/ci/scope.rb +0 -1
  18. data/lib/hashira/ci/slice.rb +21 -0
  19. data/lib/hashira/ci/sweep.rb +13 -0
  20. data/lib/hashira/cli/arguments.rb +12 -8
  21. data/lib/hashira/cli/choice.rb +9 -0
  22. data/lib/hashira/cli/command_line.rb +8 -5
  23. data/lib/hashira/cli/fail_on.rb +6 -6
  24. data/lib/hashira/cli/flag.rb +3 -1
  25. data/lib/hashira/cli/flags.rb +10 -0
  26. data/lib/hashira/cli/format.rb +1 -1
  27. data/lib/hashira/cli/needs.rb +13 -2
  28. data/lib/hashira/cli/only.rb +16 -0
  29. data/lib/hashira/cli/options.rb +8 -10
  30. data/lib/hashira/cli/package_by.rb +1 -1
  31. data/lib/hashira/cli/run.rb +9 -8
  32. data/lib/hashira/cli.rb +22 -21
  33. data/lib/hashira/complexity/cognitive_score.rb +44 -33
  34. data/lib/hashira/complexity/method_finding.rb +7 -5
  35. data/lib/hashira/complexity/method_score.rb +1 -1
  36. data/lib/hashira/complexity/scores.rb +11 -9
  37. data/lib/hashira/coupling/audiences.rb +3 -3
  38. data/lib/hashira/coupling/catalog.rb +8 -6
  39. data/lib/hashira/coupling/census.rb +42 -22
  40. data/lib/hashira/coupling/cycle_search.rb +11 -7
  41. data/lib/hashira/coupling/definitions.rb +1 -7
  42. data/lib/hashira/coupling/edge_map.rb +9 -8
  43. data/lib/hashira/coupling/folding.rb +5 -5
  44. data/lib/hashira/coupling/graph.rb +16 -9
  45. data/lib/hashira/coupling/metric.rb +3 -1
  46. data/lib/hashira/coupling/naming.rb +4 -4
  47. data/lib/hashira/coupling/references.rb +12 -12
  48. data/lib/hashira/coupling/report.rb +7 -9
  49. data/lib/hashira/coupling/roll_call.rb +1 -1
  50. data/lib/hashira/coupling/roll_call_findings.rb +1 -1
  51. data/lib/hashira/coupling/roster.rb +37 -12
  52. data/lib/hashira/coupling/rule.rb +1 -1
  53. data/lib/hashira/coupling/sdp_violation_findings.rb +3 -1
  54. data/lib/hashira/duplication/clones.rb +1 -1
  55. data/lib/hashira/duplication/clusters.rb +10 -7
  56. data/lib/hashira/duplication/delta.rb +1 -4
  57. data/lib/hashira/duplication/duplication_finding.rb +4 -2
  58. data/lib/hashira/duplication/fragment.rb +20 -2
  59. data/lib/hashira/duplication/harvest.rb +7 -5
  60. data/lib/hashira/duplication/index.rb +3 -2
  61. data/lib/hashira/duplication/literal.rb +25 -0
  62. data/lib/hashira/duplication/similarity.rb +2 -2
  63. data/lib/hashira/duplication/variance.rb +7 -1
  64. data/lib/hashira/error.rb +0 -3
  65. data/lib/hashira/focus.rb +29 -0
  66. data/lib/hashira/git_log.rb +19 -0
  67. data/lib/hashira/hotspots/file_cost.rb +1 -1
  68. data/lib/hashira/hotspots/rollup.rb +2 -2
  69. data/lib/hashira/pipeline.rb +22 -14
  70. data/lib/hashira/project.rb +35 -34
  71. data/lib/hashira/report/columns.rb +14 -8
  72. data/lib/hashira/report/graph_payload.rb +1 -1
  73. data/lib/hashira/report/hotspot_table.rb +1 -1
  74. data/lib/hashira/report/json.rb +2 -1
  75. data/lib/hashira/report/metrics_table.rb +3 -3
  76. data/lib/hashira/report/phrases.rb +23 -18
  77. data/lib/hashira/report/smell_phrases.rb +2 -2
  78. data/lib/hashira/smells/branches.rb +62 -0
  79. data/lib/hashira/smells/census.rb +11 -6
  80. data/lib/hashira/smells/check.rb +1 -5
  81. data/lib/hashira/smells/contexts.rb +7 -5
  82. data/lib/hashira/smells/control_parameter.rb +1 -1
  83. data/lib/hashira/smells/data_clump.rb +2 -4
  84. data/lib/hashira/smells/duplicate_method_call.rb +25 -5
  85. data/lib/hashira/smells/feature_envy.rb +3 -3
  86. data/lib/hashira/smells/foreign.rb +14 -14
  87. data/lib/hashira/smells/instance_variable_assumption.rb +5 -15
  88. data/lib/hashira/smells/kind.rb +9 -0
  89. data/lib/hashira/smells/lineage.rb +92 -0
  90. data/lib/hashira/smells/manual_dispatch.rb +1 -1
  91. data/lib/hashira/smells/module_initialize.rb +0 -2
  92. data/lib/hashira/smells/nil_check.rb +1 -1
  93. data/lib/hashira/smells/ownership.rb +19 -7
  94. data/lib/hashira/smells/param_check.rb +2 -2
  95. data/lib/hashira/smells/refs.rb +13 -7
  96. data/lib/hashira/smells/repeated_conditional.rb +1 -3
  97. data/lib/hashira/smells/report.rb +16 -7
  98. data/lib/hashira/smells/too_many_instance_variables.rb +3 -3
  99. data/lib/hashira/smells/visibility.rb +18 -11
  100. data/lib/hashira/trees.rb +8 -4
  101. data/lib/hashira/version.rb +1 -1
  102. data/lib/hashira.rb +12 -0
  103. metadata +13 -1
@@ -1,28 +1,32 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CLI::Arguments
4
- def initialize(argv)
5
- @argv = argv.dup
4
+ def initialize(given)
5
+ @given = given
6
6
  end
7
7
 
8
8
  def take(flag, default)
9
- position = @argv.index(flag)
9
+ position = argv.index(flag)
10
10
  return default unless position
11
- _flag, value = @argv.slice!(position, 2)
11
+ _flag, value = argv.slice!(position, 2)
12
12
  vet(flag, value)
13
13
  end
14
14
 
15
15
  def vet(flag, value)
16
16
  raise(Hashira::Error, "#{flag} needs a value") if value.to_s.strip.empty?
17
- raise(Hashira::Error, "#{flag} given more than once") if @argv.include?(flag)
17
+ raise(Hashira::Error, "#{flag} given more than once") if argv.include?(flag)
18
18
  value
19
19
  end
20
20
 
21
- def delete(flag) = @argv.delete(flag)
21
+ def delete(flag) = argv.delete(flag)
22
22
 
23
23
  def rest
24
- stray = @argv.find { it.start_with?("-") }
24
+ stray = argv.find { it.start_with?("-") }
25
25
  raise(Hashira::Error, "unknown option #{stray}") if stray
26
- @argv
26
+ argv
27
27
  end
28
+
29
+ private
30
+
31
+ def argv = @_argv ||= @given.dup
28
32
  end
@@ -0,0 +1,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira::CLI::Choice
4
+ module_function
5
+
6
+ def unknown(flag, value, choices)
7
+ Hashira::Error.new("unknown #{flag} #{value.inspect} (use: #{choices.join(", ")})")
8
+ end
9
+ end
@@ -2,23 +2,26 @@
2
2
 
3
3
  class Hashira::CLI::CommandLine
4
4
  def initialize(argv)
5
- @arguments = Hashira::CLI::Arguments.new(argv)
5
+ @argv = argv
6
6
  end
7
7
 
8
8
  def options = page || parsed
9
9
 
10
10
  private
11
11
 
12
+ def arguments = @_arguments ||= Hashira::CLI::Arguments.new(@argv)
13
+
12
14
  def page
13
15
  flag = Hashira::CLI::FLAGS.select(&:page?).find { seen?(it) }
14
- Hashira::CLI::Options.page(flag.mode) if flag
16
+ Hashira::CLI::Options.build(flag.mode) if flag
15
17
  end
16
18
 
17
- def seen?(flag) = flag.names.any? { @arguments.delete(it) }
19
+ def seen?(flag) = flag.names.any? { arguments.delete(it) }
18
20
 
19
21
  def parsed
20
- parses = Hashira::CLI::FLAGS.reject(&:page?).to_h { [it, it.read(@arguments)] }
21
- Hashira::CLI::Options.new(directories: @arguments.rest, mode: mode(parses), **fields(parses))
22
+ parses = Hashira::CLI::FLAGS.reject(&:page?).to_h { [it, it.read(arguments)] }
23
+ Hashira::CLI::Options.new(directories: arguments.rest, mode: mode(parses), **fields(parses))
24
+ .tap { Hashira::CLI::Needs.check(it) }
22
25
  end
23
26
 
24
27
  def fields(parses) = parses.to_h { |flag, value| [flag.field, value] }.except(nil)
@@ -5,12 +5,12 @@ require_relative "../pipeline"
5
5
  module Hashira::CLI::FailOn
6
6
  MEASURES = (Hashira::Pipeline::ANALYZERS - %i[coupling smells]).map(&:to_s).freeze
7
7
 
8
- KINDS = {
9
- "cycles" => "cycle", "sdp" => "sdp_violation", "dupe" => "duplication",
10
- **Hashira::Pipeline::STRUCTURAL.to_h { [it, it] },
11
- **MEASURES.to_h { [it, it] },
12
- "smells" => Hashira::Pipeline::SMELLS, **Hashira::Pipeline::SMELLS.to_h { [it, it] }
13
- }.freeze
8
+ KINDS = { "cycles" => "cycle", "sdp" => "sdp_violation", "dupe" => "duplication" }
9
+ .merge(Hashira::Pipeline::STRUCTURAL.to_h { [it, it] })
10
+ .merge(MEASURES.to_h { [it, it] })
11
+ .merge("smells" => Hashira::Pipeline::SMELLS)
12
+ .merge(Hashira::Pipeline::SMELLS.to_h { [it, it] })
13
+ .freeze
14
14
 
15
15
  OWNERS = {
16
16
  **Hashira::Pipeline::STRUCTURAL.to_h { [it, :coupling] },
@@ -1,9 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CLI
4
+ BLANK = %i[arg field parse mode].to_h { [it, nil] }.freeze
5
+
4
6
  Flag =
5
7
  Data.define(:name, :arg, :default, :field, :parse, :mode, :text) do
6
- def initialize(**attributes) = super(arg: nil, default: "", field: nil, parse: nil, mode: nil, **attributes)
8
+ def initialize(**attributes) = super(**BLANK, default: "", **attributes)
7
9
 
8
10
  def read(arguments) = arg ? take(arguments) : arguments.delete(name)
9
11
 
@@ -3,6 +3,7 @@
3
3
  require_relative "fail_on"
4
4
  require_relative "flag"
5
5
  require_relative "format"
6
+ require_relative "only"
6
7
  require_relative "package_by"
7
8
  require_relative "skip"
8
9
  require_relative "top"
@@ -31,6 +32,15 @@ class Hashira::CLI
31
32
  name: "--skip", arg: "ANALYZERS", field: :skip, parse: Skip,
32
33
  text: ["drop an analyzer; comma-separated: coupling,", "complexity, duplication, smells"]
33
34
  ),
35
+ Flag.new(
36
+ name: "--only", arg: "PATHS", field: :only, parse: Only,
37
+ text: [
38
+ "keep only the findings that name these files;",
39
+ "comma-separated. The whole project is still read,",
40
+ "so cross-file signals stay right. Meant for hooks:",
41
+ "hashira --only $CHANGED --ratchet"
42
+ ]
43
+ ),
34
44
  Flag.new(
35
45
  name: "--top", arg: "N", field: :top, parse: Top,
36
46
  text: [
@@ -8,6 +8,6 @@ module Hashira::CLI::Format
8
8
  def parse(value)
9
9
  return if value.empty?
10
10
  return value.to_sym if CHOICES.include?(value)
11
- raise(Hashira::Error.unknown("--format", value, CHOICES))
11
+ raise(Hashira::CLI::Choice.unknown("--format", value, CHOICES))
12
12
  end
13
13
  end
@@ -12,10 +12,21 @@ module Hashira::CLI::Needs
12
12
  skip = options.skip
13
13
  drawing(mode, skip)
14
14
  gate(options.fail_on, skip)
15
- shaping(mode) if options.compact
15
+ shaping(options, mode)
16
16
  end
17
17
 
18
- def shaping(mode)
18
+ def shaping(options, mode)
19
+ compacting(mode) if options.compact
20
+ focusing(mode) unless options.only.empty?
21
+ end
22
+
23
+ def focusing(mode)
24
+ raise(Hashira::Error, "--only narrows the findings, but --update-baseline records them all") if mode == :update
25
+ return unless DIAGRAMS.include?(mode)
26
+ raise(Hashira::Error, "--format #{mode} draws the coupling graph, which --only cannot narrow")
27
+ end
28
+
29
+ def compacting(mode)
19
30
  raise(Hashira::Error, "--compact shapes JSON, but this run emits #{mode}") unless mode == :json
20
31
  end
21
32
 
@@ -0,0 +1,16 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira::CLI::Only
4
+ module_function
5
+
6
+ def parse(value)
7
+ return [] if value.empty?
8
+ value.split(",").map { vet(it.strip) }
9
+ end
10
+
11
+ def vet(path)
12
+ here = path.delete_prefix("#{Dir.pwd}/").delete_prefix("./")
13
+ raise(Hashira::Error, "--only #{path.inspect} is not a file here") unless File.file?(here)
14
+ here
15
+ end
16
+ end
@@ -1,25 +1,23 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CLI
4
- PAGE = { directories: [], baseline: "", fail_on: [], skip: [], packaging: :auto, top: nil, compact: nil }.freeze
5
-
6
4
  Options =
7
- Data.define(:directories, :mode, :baseline, :fail_on, :skip, :packaging, :top, :compact) do
5
+ Data.define(:directories, :mode, :baseline, :fail_on, :skip, :only, :packaging, :top, :compact) do
8
6
  def self.parse(argv) = CommandLine.new(argv).options
9
7
 
10
- def self.page(mode) = new(**PAGE, mode:)
11
-
12
- def initialize(**attributes)
13
- super
14
- Needs.check(self)
8
+ def self.build(mode)
9
+ new(
10
+ directories: [], baseline: "", fail_on: [], skip: [], only: [],
11
+ packaging: :auto, top: nil, compact: nil, mode:
12
+ )
15
13
  end
16
14
 
17
15
  def pipeline
18
- chosen = Hashira::Project.detect(directories)
16
+ chosen = Hashira::Project.new(directories)
19
17
  told = Hashira::Report::Notices.new
20
18
  told.scanning(chosen.files.size)
21
19
  told.rails if directories.empty? && File.exist?("config/application.rb")
22
- Hashira::Pipeline.new(chosen, enabled: analyzers, packaging:)
20
+ Hashira::Pipeline.new(chosen, enabled: analyzers, packaging:, only:)
23
21
  end
24
22
 
25
23
  def analyzers = Hashira::Pipeline::ANALYZERS - skip
@@ -9,6 +9,6 @@ module Hashira::CLI::PackageBy
9
9
  return :auto if value.empty?
10
10
  choice = value.to_sym
11
11
  return choice if CHOICES.include?(choice)
12
- raise(Hashira::Error.unknown("--package-by", value, CHOICES))
12
+ raise(Hashira::CLI::Choice.unknown("--package-by", value, CHOICES))
13
13
  end
14
14
  end
@@ -1,26 +1,27 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CLI::Run
4
- MODES = { update: :update, ratchet: :check, fail_on: :guard, json: :json, dot: :diagram, mermaid: :diagram }.freeze
5
-
6
4
  def initialize(pipeline, options)
7
5
  @pipeline = pipeline
8
6
  @options = options
9
7
  end
10
8
 
9
+ MODES = { update: :update, ratchet: :check, fail_on: :guard }
10
+ .merge(json: :json, dot: :diagram, mermaid: :diagram).freeze
11
+
11
12
  def status = __send__(MODES.fetch(@options.mode, :text))
12
13
 
13
14
  private
14
15
 
15
16
  def graph = @pipeline.graph
16
17
 
17
- def findings = @findings ||= accepted.screen(@pipeline.findings)
18
+ def findings = @_findings ||= accepted.screen(@pipeline.findings)
18
19
 
19
20
  def accepted
20
21
  path = @options.baseline
21
- stop = Hashira::CI::Baseline.trouble(path)
22
+ stop = Hashira::CI::Baseline.new(path).trouble
22
23
  raise(Hashira::Error, stop) if stop
23
- Hashira::CI::Accepted.load(path)
24
+ Hashira::CI::Accepted.build(path)
24
25
  end
25
26
 
26
27
  def update
@@ -32,7 +33,7 @@ class Hashira::CLI::Run
32
33
  def check
33
34
  stop = ratchet.blocker
34
35
  raise(Hashira::Error, stop) if stop
35
- ratchet.check
36
+ ratchet.check(@pipeline.focus)
36
37
  end
37
38
 
38
39
  def guard = gate.check
@@ -55,10 +56,10 @@ class Hashira::CLI::Run
55
56
  told.unparsed(broken.size, broken.first(3).join(", ")) unless broken.empty?
56
57
  end
57
58
 
58
- def ratchet = @ratchet ||= Hashira::CI::Ratchet.new(graph, findings.all, baseline)
59
+ def ratchet = @_ratchet ||= Hashira::CI::Ratchet.new(graph, findings.all, baseline)
59
60
 
60
61
  def baseline
61
- Hashira::CI::Baseline.load(@options.baseline, analyzers: @options.analyzers, targets:)
62
+ Hashira::CI::Baseline.new(@options.baseline, analyzers: @options.analyzers, targets:)
62
63
  end
63
64
 
64
65
  def targets = @pipeline.project.directories
data/lib/hashira/cli.rb CHANGED
@@ -4,40 +4,41 @@ class Hashira::CLI
4
4
  MISUSE = 2
5
5
  BROKEN = 70
6
6
 
7
- def self.run(argv)
8
- dispatch(Options.parse(argv))
7
+ def initialize(argv)
8
+ @argv = argv
9
+ end
10
+
11
+ def status
12
+ dispatch(Options.parse(@argv))
9
13
  rescue Hashira::Error => error
10
14
  failure(error)
11
15
  rescue StandardError => error
12
16
  crash(error)
13
17
  end
14
18
 
15
- def self.dispatch(options) = usage?(options) ? Usage.public_send(options.mode) : new(options).run
19
+ private
20
+
21
+ def dispatch(options) = usage?(options) ? Usage.public_send(options.mode) : timed(options)
22
+
23
+ def usage?(options) = Usage::PAGES.include?(options.mode)
24
+
25
+ def timed(options)
26
+ started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
27
+ pipeline = options.pipeline
28
+ Run.new(pipeline, options).status.tap do
29
+ Hashira::Report::Notices.new.finished(pipeline.project.files.size, elapsed(started))
30
+ end
31
+ end
16
32
 
17
- def self.usage?(options) = Usage::PAGES.include?(options.mode)
33
+ def elapsed(started) = format("%.1f", Process.clock_gettime(Process::CLOCK_MONOTONIC) - started)
18
34
 
19
- def self.failure(error)
35
+ def failure(error)
20
36
  Kernel.warn("hashira: #{error.message}")
21
37
  MISUSE
22
38
  end
23
39
 
24
- def self.crash(error)
40
+ def crash(error)
25
41
  Hashira::Report::Notices.new.crashed(error)
26
42
  BROKEN
27
43
  end
28
-
29
- def initialize(options)
30
- @started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
31
- @options = options
32
- @notices = Hashira::Report::Notices.new
33
- @pipeline = options.pipeline
34
- end
35
-
36
- def run
37
- Run.new(@pipeline, @options).status.tap { @notices.finished(@pipeline.project.files.size, elapsed) }
38
- end
39
-
40
- private
41
-
42
- def elapsed = format("%.1f", Process.clock_gettime(Process::CLOCK_MONOTONIC) - @started)
43
44
  end
@@ -3,37 +3,35 @@
3
3
  require "prism"
4
4
 
5
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)
6
+ HANDLERS = { Prism::IfNode => :on_if, Prism::BeginNode => :on_begin, Prism::BlockNode => :on_block }
7
+ .merge(Prism::AndNode => :on_boolean, Prism::OrNode => :on_boolean, Prism::CallNode => :on_call)
8
+ .merge(Prism::UnlessNode => :on_nester, Prism::WhileNode => :on_nester, Prism::UntilNode => :on_nester)
9
+ .merge(Prism::ForNode => :on_nester, Prism::CaseNode => :on_nester, Prism::CaseMatchNode => :on_nester)
10
+ .freeze
11
+
12
+ LABELS = { Prism::UnlessNode => "unless", Prism::WhileNode => "while", Prism::UntilNode => "until" }
13
+ .merge(Prism::ForNode => "for", Prism::CaseNode => "case", Prism::CaseMatchNode => "case").freeze
14
+
15
+ def initialize(node)
16
+ @node = node
17
+ end
18
+
19
+ def increments
20
+ walked
21
+ @_increments
32
22
  end
33
23
 
34
- attr_reader :increments, :calls, :nesting
24
+ def calls
25
+ walked
26
+ @_calls
27
+ end
35
28
 
36
- def total = @increments.sum(&:cost)
29
+ def nesting
30
+ walked
31
+ @_nesting
32
+ end
33
+
34
+ def total = increments.sum(&:cost)
37
35
 
38
36
  def visit(node)
39
37
  return unless node
@@ -41,28 +39,41 @@ class Hashira::Complexity::CognitiveScore
41
39
  end
42
40
 
43
41
  def add(node, cost, label)
44
- @increments << Hashira::Complexity::Increment.new(line: node.location.start_line, cost:, label:)
42
+ @_increments << Hashira::Complexity::Increment.new(line: node.location.start_line, cost:, label:)
45
43
  end
46
44
 
47
45
  def deeper
48
- @nesting += 1
46
+ @_nesting += 1
49
47
  yield
50
- @nesting -= 1
48
+ @_nesting -= 1
51
49
  end
52
50
 
53
51
  private
54
52
 
53
+ def walked
54
+ return if @_walked
55
+ @_walked = true
56
+ blank
57
+ visit(@node.body)
58
+ end
59
+
60
+ def blank
61
+ @_increments = []
62
+ @_calls = 0
63
+ @_nesting = 0
64
+ end
65
+
55
66
  def descend(node) = node.compact_child_nodes.each { visit(it) }
56
67
 
57
68
  def on_call(node)
58
- @calls += 1
69
+ @_calls += 1
59
70
  descend(node)
60
71
  end
61
72
 
62
73
  def on_block(node) = deeper { descend(node) }
63
74
 
64
75
  def on_nester(node)
65
- add(node, 1 + @nesting, LABELS.fetch(node.class))
76
+ add(node, 1 + @_nesting, LABELS.fetch(node.class))
66
77
  deeper { descend(node) }
67
78
  end
68
79
 
@@ -3,6 +3,8 @@
3
3
  class Hashira::Complexity::MethodFinding
4
4
  KIND = "complexity"
5
5
 
6
+ Effort = Data.define(:cognitive, :calls, :site, :dominant)
7
+
6
8
  def initialize(score)
7
9
  @score = score
8
10
  end
@@ -14,16 +16,16 @@ class Hashira::Complexity::MethodFinding
14
16
  private
15
17
 
16
18
  def detail
17
- { cognitive: @score.cognitive, calls: @score.calls, site: "#{@score.file}:#{@score.line}", dominant: }
19
+ Effort.new(cognitive: @score.cognitive, calls: @score.calls, site: "#{@score.file}:#{@score.line}", dominant:)
18
20
  end
19
21
 
20
22
  def evidence
21
- @score.increments.group_by(&:label).map { |label, incs| lines(label, incs) }
23
+ @score.increments.group_by(&:label).map { |label, increments| lines(label, increments) }
22
24
  end
23
25
 
24
- def lines(label, incs)
25
- lines = incs.map(&:line).uniq
26
- "#{label} +#{incs.sum(&:cost)} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
26
+ def lines(label, increments)
27
+ lines = increments.map(&:line).uniq
28
+ "#{label} +#{increments.sum(&:cost)} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
27
29
  end
28
30
 
29
31
  def dominant
@@ -6,7 +6,7 @@ module Hashira
6
6
 
7
7
  MethodScore =
8
8
  Data.define(:subject, :file, :line, :cognitive, :calls, :increments) do
9
- def to_h = { subject:, file:, line:, cognitive:, calls: }
9
+ def to_h = super.except(:increments)
10
10
 
11
11
  def cells = [subject, cognitive, calls, "#{file}:#{line}"]
12
12
  end
@@ -5,35 +5,37 @@ class Hashira::Complexity::Scores
5
5
 
6
6
  def initialize(project, trees)
7
7
  @project = project
8
- @scores = trees.flat_map { |path, tree| harvest(path, tree) }
8
+ @trees = trees
9
9
  end
10
10
 
11
- def ranked = @scores.sort_by { -it.cognitive }
11
+ def ranked = scores.sort_by { -it.cognitive }
12
12
 
13
- def classes = Hashira::Complexity::Rollup.new(@scores).classes.sort_by { -it.cognitive }
13
+ def classes = Hashira::Complexity::Rollup.new(scores).classes.sort_by { -it.cognitive }
14
14
 
15
15
  def findings = flagged.map { Hashira::Complexity::MethodFinding.new(it).to_finding }
16
16
 
17
17
  private
18
18
 
19
+ def scores = @_scores ||= @trees.flat_map { |path, tree| harvest(path, tree) }
20
+
19
21
  def flagged = ranked.select { it.cognitive >= THRESHOLD }
20
22
 
21
23
  def harvest(path, tree)
22
- rel = @project.relative(path)
23
- sites(tree).map { |full, node| score(rel, full, node) }
24
+ relative = @project.relative(path)
25
+ sites(tree).map { |full, node| score(relative, full, node) }
24
26
  end
25
27
 
26
28
  def sites(tree)
27
29
  found = []
28
- Hashira::Analysis::TypeWalk.each(tree) do |type_node, full|
29
- Hashira::Analysis::Syntax.direct(type_node).each { found << [full, it] }
30
+ Hashira::Analysis::TypeWalk.each(tree) do |type, full|
31
+ Hashira::Analysis::Syntax.direct(type).each { found << [full, it] }
30
32
  end
31
33
  found
32
34
  end
33
35
 
34
- def score(rel, full, node)
36
+ def score(relative, full, node)
35
37
  Hashira::Complexity::MethodScore.new(
36
- subject: subject(full, node), file: rel, line: node.location.start_line,
38
+ subject: subject(full, node), file: relative, line: node.location.start_line,
37
39
  **tallies(Hashira::Complexity::CognitiveScore.new(node))
38
40
  )
39
41
  end
@@ -11,13 +11,13 @@ class Hashira::Coupling::Audiences
11
11
 
12
12
  def split? = parts.size >= 2
13
13
 
14
- def parts = @parts ||= [common, *slices].compact.select { it.constants.size >= MIN }
14
+ def parts = @_parts ||= [common, *slices].compact.select { it.constants.size >= MIN }
15
15
 
16
16
  private
17
17
 
18
- def clients = @clients ||= @usage.keys.sort
18
+ def clients = @_clients ||= @usage.keys.sort
19
19
 
20
- def shared = @shared ||= tally.select { |_constant, users| users.size * 2 > clients.size }.keys.to_set
20
+ def shared = @_shared ||= tally.select { |_constant, users| users.size * 2 > clients.size }.keys.to_set
21
21
 
22
22
  def tally
23
23
  clients.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |client, seen|
@@ -5,15 +5,13 @@ class Hashira::Coupling::Catalog
5
5
 
6
6
  def initialize(definitions)
7
7
  @definitions = definitions
8
- @naming = Hashira::Coupling::Naming.new(definitions)
9
- @entries = definitions.map { |node, full, folder| entry(node, full, folder) }
10
8
  end
11
9
 
12
- def each(&) = @entries.each(&)
10
+ def each(&) = entries.each(&)
13
11
 
14
- def prefix = @naming.segments
12
+ def prefix = naming.segments
15
13
 
16
- def strip(path) = @naming.strip(path)
14
+ def strip(path) = naming.strip(path)
17
15
 
18
16
  def roots = @definitions.roots
19
17
 
@@ -21,7 +19,11 @@ class Hashira::Coupling::Catalog
21
19
 
22
20
  private
23
21
 
22
+ def naming = @_naming ||= Hashira::Coupling::Naming.new(@definitions)
23
+
24
+ def entries = @_entries ||= @definitions.map { |node, full, folder| entry(node, full, folder) }
25
+
24
26
  def entry(node, full, folder)
25
- Hashira::Coupling::Definition.new(node:, path: @naming.strip(full), folder:)
27
+ Hashira::Coupling::Definition.new(node:, path: naming.strip(full), folder:)
26
28
  end
27
29
  end