hashira 0.1.0 → 0.3.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 (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +334 -32
  4. data/lib/hashira/analysis/census.rb +12 -14
  5. data/lib/hashira/analysis/constant_registry.rb +40 -0
  6. data/lib/hashira/analysis/cycle_findings.rb +1 -1
  7. data/lib/hashira/analysis/cycle_search.rb +1 -1
  8. data/lib/hashira/analysis/definitions.rb +1 -1
  9. data/lib/hashira/analysis/finding.rb +6 -2
  10. data/lib/hashira/analysis/graph.rb +3 -3
  11. data/lib/hashira/analysis/namespace_prefix.rb +32 -0
  12. data/lib/hashira/analysis/node_walk.rb +7 -1
  13. data/lib/hashira/analysis/references.rb +2 -2
  14. data/lib/hashira/analysis/sdp_check.rb +1 -1
  15. data/lib/hashira/analysis/type_walk.rb +1 -1
  16. data/lib/hashira/churn.rb +22 -0
  17. data/lib/hashira/ci/accepted.rb +11 -7
  18. data/lib/hashira/ci/baseline.rb +40 -0
  19. data/lib/hashira/ci/diff.rb +15 -0
  20. data/lib/hashira/ci/edge_diff_report.rb +4 -27
  21. data/lib/hashira/ci/finding_diff_report.rb +26 -0
  22. data/lib/hashira/ci/gate.rb +7 -5
  23. data/lib/hashira/ci/improvement.rb +19 -0
  24. data/lib/hashira/ci/ratchet.rb +16 -20
  25. data/lib/hashira/ci/ratchet_report.rb +42 -0
  26. data/lib/hashira/cli/command_line.rb +12 -6
  27. data/lib/hashira/cli/fail_on.rb +4 -2
  28. data/lib/hashira/cli/options.rb +1 -1
  29. data/lib/hashira/cli/run.rb +9 -3
  30. data/lib/hashira/cli/skip.rb +27 -0
  31. data/lib/hashira/cli/usage.rb +10 -5
  32. data/lib/hashira/cli.rb +1 -1
  33. data/lib/hashira/complexity/analyzer.rb +45 -0
  34. data/lib/hashira/complexity/boolean_run.rb +22 -0
  35. data/lib/hashira/complexity/cognitive_score.rb +72 -0
  36. data/lib/hashira/complexity/if_chain.rb +45 -0
  37. data/lib/hashira/complexity/method_finding.rb +52 -0
  38. data/lib/hashira/complexity/method_score.rb +17 -0
  39. data/lib/hashira/complexity/rescue_scan.rb +28 -0
  40. data/lib/hashira/complexity/rollup.rb +22 -0
  41. data/lib/hashira/duplication/analyzer.rb +21 -0
  42. data/lib/hashira/duplication/cluster.rb +26 -0
  43. data/lib/hashira/duplication/clusterer.rb +50 -0
  44. data/lib/hashira/duplication/delta.rb +39 -0
  45. data/lib/hashira/duplication/duplication_finding.rb +30 -0
  46. data/lib/hashira/duplication/extractor.rb +31 -0
  47. data/lib/hashira/duplication/fragment.rb +42 -0
  48. data/lib/hashira/duplication/grouping.rb +24 -0
  49. data/lib/hashira/duplication/index.rb +35 -0
  50. data/lib/hashira/duplication/maximal.rb +21 -0
  51. data/lib/hashira/duplication/near_miss.rb +33 -0
  52. data/lib/hashira/duplication/sequence.rb +36 -0
  53. data/lib/hashira/duplication/similarity.rb +50 -0
  54. data/lib/hashira/duplication/union_find.rb +21 -0
  55. data/lib/hashira/duplication/variance.rb +57 -0
  56. data/lib/hashira/hotspots/file_cost.rb +19 -0
  57. data/lib/hashira/hotspots/rollup.rb +33 -0
  58. data/lib/hashira/pipeline.rb +28 -6
  59. data/lib/hashira/project.rb +32 -9
  60. data/lib/hashira/report/complexity_table.rb +40 -0
  61. data/lib/hashira/report/dependency_map.rb +8 -5
  62. data/lib/hashira/report/finding_lines.rb +1 -1
  63. data/lib/hashira/report/graph_payload.rb +29 -0
  64. data/lib/hashira/report/hotspot_table.rb +41 -0
  65. data/lib/hashira/report/json.rb +24 -17
  66. data/lib/hashira/report/metrics_table.rb +1 -2
  67. data/lib/hashira/report/text.rb +24 -14
  68. data/lib/hashira/report/view.rb +7 -0
  69. data/lib/hashira/version.rb +1 -1
  70. data/lib/hashira.rb +38 -1
  71. metadata +46 -7
  72. data/lib/hashira/analysis/root_namespace.rb +0 -14
@@ -14,7 +14,7 @@ module Hashira
14
14
  @trees.each { |file, tree| definitions_in(file, tree, &) }
15
15
  end
16
16
 
17
- def packages = @trees.keys.map { @project.package_for(_1) }.uniq
17
+ def packages = @trees.keys.map { @project.package_for(it) }.uniq
18
18
 
19
19
  private
20
20
 
@@ -2,8 +2,12 @@
2
2
 
3
3
  module Hashira
4
4
  module Analysis
5
- Finding = Data.define(:kind, :package, :message, :evidence, :cycle) do
6
- def initialize(cycle: nil, **rest) = super
5
+ Finding = Data.define(:kind, :package, :message, :evidence, :cycle, :digest) do
6
+ def initialize(cycle: nil, digest: nil, **rest) = super
7
+
8
+ def signature = "#{kind}:#{identity}"
9
+
10
+ def identity = digest || package
7
11
 
8
12
  def to_h = super.compact
9
13
  end
@@ -13,10 +13,10 @@ module Hashira
13
13
 
14
14
  def dependencies_of(package) = dependencies[package].to_a.sort
15
15
 
16
- def dependents_of(package) = packages.select { dependencies[_1].include?(package) }.sort
16
+ def dependents_of(package) = packages.select { dependencies[it].include?(package) }.sort
17
17
 
18
18
  def edge_list
19
- dependencies.sort.flat_map { |from, tos| tos.sort.map { Edge.new(from:, to: _1) } }
19
+ dependencies.sort.flat_map { |from, tos| tos.sort.map { Edge.new(from:, to: it) } }
20
20
  end
21
21
 
22
22
  def weighted_edges
@@ -34,7 +34,7 @@ module Hashira
34
34
  efferent: dependencies[package].size)
35
35
  end
36
36
 
37
- def metrics = packages.to_h { [_1, metric_for(_1)] }
37
+ def metrics = packages.to_h { [it, metric_for(it)] }
38
38
 
39
39
  def sdp_violations = SdpCheck.new(dependencies, metrics).violations
40
40
 
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Analysis
5
+ module NamespacePrefix
6
+ module_function
7
+
8
+ def infer(definitions)
9
+ build(deepest_definition_per_package(definitions))
10
+ end
11
+
12
+ def build(fulls, prefix = [])
13
+ depth = prefix.length
14
+ wrapper = shared_wrapper(fulls, depth)
15
+ return prefix unless wrapper
16
+
17
+ build(fulls.select { it[depth] == wrapper }, prefix + [wrapper])
18
+ end
19
+
20
+ def deepest_definition_per_package(definitions)
21
+ definitions.map { |_node, full, package| [package, full] }
22
+ .sort_by { |_package, full| full.length }
23
+ .to_h.values
24
+ end
25
+
26
+ def shared_wrapper(fulls, depth)
27
+ wrapper, count = fulls.filter_map { it[depth] if it[depth + 1] }.tally.max_by { |_wrapper, tally| tally }
28
+ wrapper if wrapper && count > fulls.size / 2
29
+ end
30
+ end
31
+ end
32
+ end
@@ -7,7 +7,13 @@ module Hashira
7
7
 
8
8
  def each_node(node, &)
9
9
  yield(node)
10
- node.compact_child_nodes.each { each_node(_1, &) }
10
+ node.compact_child_nodes.each { each_node(it, &) }
11
+ end
12
+
13
+ def collect(node)
14
+ found = []
15
+ each_node(node) { found << it }
16
+ found
11
17
  end
12
18
  end
13
19
  end
@@ -12,14 +12,14 @@ module Hashira
12
12
  end
13
13
 
14
14
  def each_sighting(tree)
15
- [].tap { collect(tree, _1) }
15
+ [].tap { collect(tree, it) }
16
16
  end
17
17
 
18
18
  def collect(node, accumulator)
19
19
  return unless node
20
20
  return accumulator << [Syntax.path_segments(node), node.location.start_line] if constant?(node)
21
21
 
22
- branches(node).each { collect(_1, accumulator) }
22
+ branches(node).each { collect(it, accumulator) }
23
23
  end
24
24
 
25
25
  def constant?(node) = node.is_a?(Prism::ConstantPathNode) || node.is_a?(Prism::ConstantReadNode)
@@ -10,7 +10,7 @@ module Hashira
10
10
 
11
11
  def violations
12
12
  @dependencies.flat_map do |from, tos|
13
- tos.select { @metrics[_1].instability > @metrics[from].instability }.map { [from, _1] }
13
+ tos.select { @metrics[it].instability > @metrics[from].instability }.map { [from, it] }
14
14
  end
15
15
  end
16
16
  end
@@ -19,7 +19,7 @@ module Hashira
19
19
 
20
20
  def each_child_definition(node, prefix, &)
21
21
  children = node ? node.compact_child_nodes : []
22
- children.each { each_definition(_1, prefix, &) }
22
+ children.each { each_definition(it, prefix, &) }
23
23
  end
24
24
  end
25
25
  end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ class Churn
5
+ LOG = "git log --name-only --format= 2>/dev/null"
6
+ SITES_THAT_DRIFT_APART = 2
7
+
8
+ def self.from_git = new(tally(`#{LOG}`))
9
+
10
+ def self.tally(output) = output.split("\n").map(&:strip).reject(&:empty?).tally
11
+
12
+ def initialize(counts)
13
+ @counts = counts
14
+ end
15
+
16
+ def hits(file) = @counts.select { |path, _| path.end_with?(file) }.values.max || 0
17
+
18
+ def hot?(members) = changing(members) >= SITES_THAT_DRIFT_APART
19
+
20
+ def changing(members) = members.count { |member| hits(member.file).positive? }
21
+ end
22
+ end
@@ -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