hashira 0.6.0 → 0.7.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 (44) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +68 -0
  3. data/README.md +119 -53
  4. data/lib/hashira/analysis/finding.rb +4 -0
  5. data/lib/hashira/churn.rb +10 -2
  6. data/lib/hashira/ci/accepted.rb +1 -4
  7. data/lib/hashira/ci/baseline.rb +28 -5
  8. data/lib/hashira/ci/diff.rb +15 -4
  9. data/lib/hashira/ci/finding_diff_report.rb +10 -0
  10. data/lib/hashira/ci/gate.rb +10 -2
  11. data/lib/hashira/ci/improvement.rb +1 -1
  12. data/lib/hashira/ci/ratchet.rb +33 -8
  13. data/lib/hashira/ci/ratchet_report.rb +4 -2
  14. data/lib/hashira/ci/scope.rb +11 -0
  15. data/lib/hashira/ci/status.rb +7 -0
  16. data/lib/hashira/cli/arguments.rb +6 -1
  17. data/lib/hashira/cli/fail_on.rb +12 -2
  18. data/lib/hashira/cli/flags.rb +13 -0
  19. data/lib/hashira/cli/needs.rb +34 -0
  20. data/lib/hashira/cli/options.rb +14 -3
  21. data/lib/hashira/cli/run.rb +32 -5
  22. data/lib/hashira/cli/top.rb +13 -0
  23. data/lib/hashira/cli/usage.rb +5 -1
  24. data/lib/hashira/cli.rb +24 -5
  25. data/lib/hashira/coupling/metric.rb +7 -1
  26. data/lib/hashira/diagram/dot.rb +9 -6
  27. data/lib/hashira/diagram/mermaid.rb +11 -8
  28. data/lib/hashira/diagram/source.rb +5 -2
  29. data/lib/hashira/pipeline.rb +12 -18
  30. data/lib/hashira/project.rb +19 -5
  31. data/lib/hashira/report/columns.rb +48 -0
  32. data/lib/hashira/report/complexity_table.rb +12 -16
  33. data/lib/hashira/report/dependency_map.rb +1 -0
  34. data/lib/hashira/report/hotspot_table.rb +6 -14
  35. data/lib/hashira/report/json.rb +9 -2
  36. data/lib/hashira/report/metrics_table.rb +19 -20
  37. data/lib/hashira/report/notices.rb +37 -0
  38. data/lib/hashira/report/phrases.rb +2 -0
  39. data/lib/hashira/report/text.rb +24 -9
  40. data/lib/hashira/report/view.rb +4 -1
  41. data/lib/hashira/trees.rb +22 -0
  42. data/lib/hashira/version.rb +1 -1
  43. data/lib/hashira.rb +6 -0
  44. metadata +8 -1
@@ -0,0 +1,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module CI
5
+ Scope =
6
+ Data.define(:analyzers, :targets) do
7
+ def self.none = new(analyzers: [], targets: [])
8
+ def to_h = { analyzers: analyzers.map(&:to_s).sort, targets: targets.sort }
9
+ end
10
+ end
11
+ end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira::CI::Status
4
+ CLEAN = 0
5
+ WORSE = 1
6
+ BETTER = 3
7
+ end
@@ -9,7 +9,12 @@ class Hashira::CLI::Arguments
9
9
  position = @argv.index(flag)
10
10
  return default unless position
11
11
  _flag, value = @argv.slice!(position, 2)
12
- raise(Hashira::Error, "#{flag} needs a value") unless value
12
+ vet(flag, value)
13
+ end
14
+
15
+ def vet(flag, value)
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)
13
18
  value
14
19
  end
15
20
 
@@ -12,13 +12,23 @@ module Hashira::CLI::FailOn
12
12
  "smells" => Hashira::Pipeline::SMELLS, **Hashira::Pipeline::SMELLS.to_h { [it, it] }
13
13
  }.freeze
14
14
 
15
+ OWNERS = {
16
+ **Hashira::Pipeline::STRUCTURAL.to_h { [it, :coupling] },
17
+ **MEASURES.to_h { [it, it.to_sym] },
18
+ **Hashira::Pipeline::SMELLS.to_h { [it, :smells] }
19
+ }.freeze
20
+
15
21
  module_function
16
22
 
17
23
  def parse(list)
18
- return [] unless list
19
- list.split(",").flat_map { Array(kind(it.strip)) }.uniq
24
+ return [] if list.to_s.empty?
25
+ kinds = list.split(",").flat_map { Array(kind(it.strip)) }.uniq
26
+ raise(Hashira::Error, "--fail-on needs at least one kind") if kinds.empty?
27
+ kinds
20
28
  end
21
29
 
30
+ def owner(kind) = OWNERS.fetch(kind)
31
+
22
32
  def kind(name)
23
33
  KINDS.fetch(name) do
24
34
  raise(Hashira::Error, "unknown --fail-on kind #{name.inspect} (use: #{KINDS.keys.join(", ")})")
@@ -5,6 +5,7 @@ require_relative "flag"
5
5
  require_relative "format"
6
6
  require_relative "package_by"
7
7
  require_relative "skip"
8
+ require_relative "top"
8
9
 
9
10
  class Hashira::CLI
10
11
  FLAGS = [
@@ -13,6 +14,10 @@ class Hashira::CLI
13
14
  text: ["text (default), json, dot, or mermaid"]
14
15
  ),
15
16
  Flag.new(name: "--json", mode: :json, text: ["shorthand for --format json"]),
17
+ Flag.new(
18
+ name: "--compact", field: :compact,
19
+ text: ["emit JSON on one line, without the indentation", "meant for reading"]
20
+ ),
16
21
  Flag.new(
17
22
  name: "--fail-on", arg: "KINDS", field: :fail_on, parse: FailOn, mode: :fail_on,
18
23
  text: [
@@ -26,6 +31,14 @@ class Hashira::CLI
26
31
  name: "--skip", arg: "ANALYZERS", field: :skip, parse: Skip,
27
32
  text: ["drop an analyzer; comma-separated: coupling,", "complexity, duplication, smells"]
28
33
  ),
34
+ Flag.new(
35
+ name: "--top", arg: "N", field: :top, parse: Top,
36
+ text: [
37
+ "show at most N rows in each table and N",
38
+ "findings (default: 25 packages and findings,",
39
+ "10 methods and files)"
40
+ ]
41
+ ),
29
42
  Flag.new(
30
43
  name: "--package-by", arg: "WHAT", field: :packaging, parse: PackageBy,
31
44
  text: [
@@ -0,0 +1,34 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "fail_on"
4
+
5
+ module Hashira::CLI::Needs
6
+ DIAGRAMS = %i[dot mermaid].freeze
7
+
8
+ module_function
9
+
10
+ def check(options)
11
+ mode = options.mode
12
+ skip = options.skip
13
+ drawing(mode, skip)
14
+ gate(options.fail_on, skip)
15
+ shaping(mode) if options.compact
16
+ end
17
+
18
+ def shaping(mode)
19
+ raise(Hashira::Error, "--compact shapes JSON, but this run emits #{mode}") unless mode == :json
20
+ end
21
+
22
+ def drawing(mode, skip)
23
+ return unless DIAGRAMS.include?(mode) && skip.include?(:coupling)
24
+ raise(Hashira::Error, "--format #{mode} draws the coupling graph, but --skip coupling drops it")
25
+ end
26
+
27
+ def gate(fail_on, skip)
28
+ blind = fail_on.find { skip.include?(owner(it)) }
29
+ return unless blind
30
+ raise(Hashira::Error, "--fail-on #{blind} needs the #{owner(blind)} analyzer, but --skip drops it")
31
+ end
32
+
33
+ def owner(kind) = Hashira::CLI::FailOn.owner(kind)
34
+ end
@@ -1,14 +1,25 @@
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
+
4
6
  Options =
5
- Data.define(:directories, :mode, :baseline, :fail_on, :skip, :packaging) do
7
+ Data.define(:directories, :mode, :baseline, :fail_on, :skip, :packaging, :top, :compact) do
6
8
  def self.parse(argv) = CommandLine.new(argv).options
7
9
 
8
- def self.page(mode) = new(directories: [], mode:, baseline: "", fail_on: [], skip: [], packaging: :auto)
10
+ def self.page(mode) = new(**PAGE, mode:)
11
+
12
+ def initialize(**attributes)
13
+ super
14
+ Needs.check(self)
15
+ end
9
16
 
10
17
  def pipeline
11
- Hashira::Pipeline.new(Hashira::Project.detect(directories), enabled: analyzers, packaging:)
18
+ chosen = Hashira::Project.detect(directories)
19
+ told = Hashira::Report::Notices.new
20
+ told.scanning(chosen.files.size)
21
+ told.rails if directories.empty? && File.exist?("config/application.rb")
22
+ Hashira::Pipeline.new(chosen, enabled: analyzers, packaging:)
12
23
  end
13
24
 
14
25
  def analyzers = Hashira::Pipeline::ANALYZERS - skip
@@ -14,9 +14,20 @@ class Hashira::CLI::Run
14
14
 
15
15
  def graph = @pipeline.graph
16
16
 
17
- def findings = @findings ||= Hashira::CI::Accepted.load(@options.baseline).screen(@pipeline.findings)
17
+ def findings = @findings ||= accepted.screen(@pipeline.findings)
18
18
 
19
- def update = ratchet.update
19
+ def accepted
20
+ path = @options.baseline
21
+ stop = Hashira::CI::Baseline.trouble(path)
22
+ raise(Hashira::Error, stop) if stop
23
+ Hashira::CI::Accepted.load(path)
24
+ end
25
+
26
+ def update
27
+ ratchet.update
28
+ rescue SystemCallError => error
29
+ raise(Hashira::Error, "cannot write #{@options.baseline} (#{error.message})")
30
+ end
20
31
 
21
32
  def check
22
33
  stop = ratchet.blocker
@@ -32,9 +43,25 @@ class Hashira::CLI::Run
32
43
 
33
44
  def text = report(Hashira::Report::Text)
34
45
 
35
- def report(kind) = kind.new(view).print
46
+ def report(kind)
47
+ notices
48
+ kind.new(view).print
49
+ end
50
+
51
+ def notices
52
+ told = Hashira::Report::Notices.new
53
+ told.churn(@pipeline.project.label) unless @pipeline.churn.history?
54
+ broken = @pipeline.unparsed
55
+ told.unparsed(broken.size, broken.first(3).join(", ")) unless broken.empty?
56
+ end
57
+
58
+ def ratchet = @ratchet ||= Hashira::CI::Ratchet.new(graph, findings.all, baseline)
59
+
60
+ def baseline
61
+ Hashira::CI::Baseline.load(@options.baseline, analyzers: @options.analyzers, targets:)
62
+ end
36
63
 
37
- def ratchet = @ratchet ||= Hashira::CI::Ratchet.new(graph, findings.all, @options.baseline)
64
+ def targets = @pipeline.project.directories
38
65
 
39
66
  def gate = Hashira::CI::Gate.new(findings, @options.fail_on)
40
67
 
@@ -44,7 +71,7 @@ class Hashira::CLI::Run
44
71
  Hashira::Report::View.new(
45
72
  project: @pipeline.project, graph: (graph if @pipeline.enabled?(:coupling)),
46
73
  complexity: @pipeline.complexity, duplication: @pipeline.duplication,
47
- hotspots: @pipeline.hotspots, findings:
74
+ hotspots: @pipeline.hotspots, findings:, top: @options.top, compact: @options.compact
48
75
  )
49
76
  end
50
77
  end
@@ -0,0 +1,13 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira::CLI::Top
4
+ WHOLE = /\A[1-9]\d*\z/
5
+
6
+ module_function
7
+
8
+ def parse(value)
9
+ return if value.empty?
10
+ return Integer(value, 10) if value.match?(WHOLE)
11
+ raise(Hashira::Error, "--top #{value.inspect} is not a positive whole number")
12
+ end
13
+ end
@@ -17,6 +17,10 @@ module Hashira::CLI::Usage
17
17
 
18
18
  FOOTER = <<~TEXT
19
19
 
20
+ Exit codes: 0 clean · 1 findings or a regression · 2 misuse
21
+ (bad flags, missing directory, unusable baseline) · 3 an
22
+ improvement the baseline has not recorded · 70 internal error.
23
+
20
24
  Findings accepted by design can be recorded in the baseline as
21
25
  "accepted": [{"kind": "...", "package": "...", "reason": "..."}]
22
26
  — they leave reports and gates, keeping a one-line reminder each.
@@ -38,7 +42,7 @@ module Hashira::CLI::Usage
38
42
  end
39
43
 
40
44
  def emit(output)
41
- puts(output)
45
+ $stdout.puts(output)
42
46
  0
43
47
  end
44
48
  end
data/lib/hashira/cli.rb CHANGED
@@ -1,24 +1,43 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CLI
4
+ MISUSE = 2
5
+ BROKEN = 70
6
+
4
7
  def self.run(argv)
5
- options = Options.parse(argv)
6
- usage?(options) ? Usage.public_send(options.mode) : new(options).run
8
+ dispatch(Options.parse(argv))
7
9
  rescue Hashira::Error => error
8
10
  failure(error)
11
+ rescue StandardError => error
12
+ crash(error)
9
13
  end
10
14
 
15
+ def self.dispatch(options) = usage?(options) ? Usage.public_send(options.mode) : new(options).run
16
+
11
17
  def self.usage?(options) = Usage::PAGES.include?(options.mode)
12
18
 
13
19
  def self.failure(error)
14
- warn("hashira: #{error.message}")
15
- 1
20
+ Kernel.warn("hashira: #{error.message}")
21
+ MISUSE
22
+ end
23
+
24
+ def self.crash(error)
25
+ Hashira::Report::Notices.new.crashed(error)
26
+ BROKEN
16
27
  end
17
28
 
18
29
  def initialize(options)
30
+ @started = Process.clock_gettime(Process::CLOCK_MONOTONIC)
19
31
  @options = options
32
+ @notices = Hashira::Report::Notices.new
20
33
  @pipeline = options.pipeline
21
34
  end
22
35
 
23
- def run = Run.new(@pipeline, @options).status
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)
24
43
  end
@@ -9,7 +9,13 @@ module Hashira
9
9
  total.zero? ? 0.0 : efferent.fdiv(total)
10
10
  end
11
11
 
12
- def to_h = { tc: types, ca: afferent, ce: efferent, i: instability }
12
+ def isolated? = (efferent + afferent).zero?
13
+
14
+ def order = [isolated? ? 1 : 0, instability]
15
+
16
+ def to_h = { tc: types, ca: afferent, ce: efferent, i: (instability unless isolated?) }
17
+
18
+ def cells = [types, afferent, efferent, isolated? ? "—" : format("%.2f", instability)]
13
19
  end
14
20
  end
15
21
  end
@@ -1,13 +1,16 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Diagram::Dot
4
- def initialize(edges)
4
+ def initialize(edges, packages)
5
5
  @edges = edges
6
+ @packages = packages
6
7
  end
7
8
 
8
- def source
9
- "digraph hashira {\n rankdir=LR;\n#{@edges.map do |from, to, weight|
10
- %( "#{from}" -> "#{to}" [label="#{weight}"];)
11
- end.join("\n")}\n}"
12
- end
9
+ def source = "digraph hashira {\n rankdir=LR;\n#{lines.join("\n")}\n}"
10
+
11
+ private
12
+
13
+ def lines = @packages.map { %( "#{it}";) } + @edges.map { |from, to, weight| link(from, to, weight) }
14
+
15
+ def link(from, to, weight) = %( "#{from}" -> "#{to}" [label="#{weight}"];)
13
16
  end
@@ -1,18 +1,21 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Diagram::Mermaid
4
- def initialize(edges)
4
+ def initialize(edges, packages)
5
5
  @edges = edges
6
- @nodes = {}
6
+ @packages = packages
7
+ @ids = {}
7
8
  end
8
9
 
9
- def source
10
- "graph LR\n#{@edges.map { |from, to, weight| " #{node(from)} -->|#{weight}| #{node(to)}" }.join("\n")}"
11
- end
10
+ def source = "graph LR\n#{lines.join("\n")}"
12
11
 
13
12
  private
14
13
 
15
- def node(package)
16
- @nodes[package] ||= "#{package.gsub(/\W/, "_")}[\"#{package}\"]"
17
- end
14
+ def lines = @packages.map { " #{declare(it)}" } + @edges.map { |from, to, weight| link(from, to, weight) }
15
+
16
+ def link(from, to, weight) = " #{id(from)} -->|#{weight}| #{id(to)}"
17
+
18
+ def declare(package) = "#{id(package)}[#{package.to_json}]"
19
+
20
+ def id(package) = @ids[package] ||= "p#{@ids.size}"
18
21
  end
@@ -8,8 +8,11 @@ class Hashira::Diagram::Source
8
8
  end
9
9
 
10
10
  def print
11
- edges = @graph.weighted
12
- @io.puts((@format == :dot ? Hashira::Diagram::Dot.new(edges) : Hashira::Diagram::Mermaid.new(edges)).source)
11
+ @io.puts(drawing.new(@graph.weighted, @graph.packages.sort).source)
13
12
  0
14
13
  end
14
+
15
+ private
16
+
17
+ def drawing = @format == :dot ? Hashira::Diagram::Dot : Hashira::Diagram::Mermaid
15
18
  end
@@ -16,34 +16,34 @@ class Hashira::Pipeline
16
16
  def initialize(project, enabled: ANALYZERS, packaging: :auto)
17
17
  @project = project
18
18
  @enabled = enabled
19
- @trees = project.files.to_h { [it, parse(it)] }
20
- @coupling = Hashira::Coupling::Report.new(project, @trees, packaging: settle(packaging))
19
+ @parsed = Hashira::Trees.new(project)
20
+ @coupling = Hashira::Coupling::Report.new(project, @parsed.all, packaging: settle(packaging))
21
21
  end
22
22
 
23
23
  attr_reader :project
24
24
 
25
+ def unparsed = @parsed.unparsed
26
+
25
27
  def graph = @coupling.graph
26
28
 
27
- def complexity
28
- @complexity ||= Hashira::Complexity::Scores.new(@project, @trees) if enabled?(:complexity)
29
- end
29
+ def complexity = @complexity ||= analyzed(:complexity, Hashira::Complexity::Scores)
30
30
 
31
- def duplication
32
- @duplication ||= Hashira::Duplication::Clones.new(@project, @trees, churn) if enabled?(:duplication)
33
- end
31
+ def duplication = @duplication ||= analyzed(:duplication, Hashira::Duplication::Clones, churn)
34
32
 
35
- def smells
36
- @smells ||= Hashira::Smells::Report.new(@project, @trees) if enabled?(:smells)
37
- end
33
+ def smells = @smells ||= analyzed(:smells, Hashira::Smells::Report)
38
34
 
39
35
  def hotspots
40
36
  @hotspots ||= Hashira::Hotspots::Rollup.new(complexity, duplication, churn) if complexity || duplication
41
37
  end
42
38
 
43
- def churn = @churn ||= Hashira::Churn.scan
39
+ def churn = @churn ||= Hashira::Churn.scan(@project.directories.first)
44
40
 
45
41
  def enabled?(analyzer) = @enabled.include?(analyzer)
46
42
 
43
+ def analyzed(name, kind, *extra)
44
+ kind.new(@project, @parsed.all, *extra) if enabled?(name)
45
+ end
46
+
47
47
  def findings = structural + listed(complexity) + listed(duplication) + listed(smells)
48
48
 
49
49
  private
@@ -56,10 +56,4 @@ class Hashira::Pipeline
56
56
  def structural = enabled?(:coupling) ? @coupling.findings : []
57
57
 
58
58
  def listed(analyzer) = analyzer ? analyzer.findings : []
59
-
60
- def parse(path)
61
- Prism.parse_file(path).value
62
- rescue SystemCallError => error
63
- raise(Hashira::Error, "cannot read #{path} (#{error.message})")
64
- end
65
59
  end
@@ -8,8 +8,8 @@ class Hashira::Project
8
8
  end
9
9
 
10
10
  def self.defaults
11
- raise(Hashira::Error, "no lib/ directory here — pass the source directory explicitly") if Dir["lib/*/"].empty?
12
- ["lib"]
11
+ return ["lib"] if Dir["lib/*/"].any? || Dir["lib/*.rb"].any?
12
+ raise(Hashira::Error, "no lib/ directory here — pass the source directory explicitly")
13
13
  end
14
14
  private_class_method :defaults
15
15
 
@@ -26,9 +26,9 @@ class Hashira::Project
26
26
  private_class_method :descend, :sole
27
27
 
28
28
  def initialize(directories)
29
- missing = directories.reject { Dir.exist?(it) }
30
- raise(Hashira::Error, "no such directory: #{missing.join(", ")}") unless missing.empty?
31
- @directories = directories.map { it.delete_suffix("/") }
29
+ vet(directories)
30
+ @directories = distinct(directories.map { it.delete_suffix("/") })
31
+ raise(Hashira::Error, "no Ruby files under #{label}") if files.empty?
32
32
  @rails = @directories.any? { config?(File.expand_path(it)) }
33
33
  end
34
34
 
@@ -52,6 +52,20 @@ class Hashira::Project
52
52
 
53
53
  private
54
54
 
55
+ def vet(directories)
56
+ file = directories.find { File.file?(it) }
57
+ raise(Hashira::Error, "#{file} is a file — hashira takes directories (try: hashira #{File.dirname(file)})") if file
58
+ missing = directories.reject { Dir.exist?(it) }
59
+ raise(Hashira::Error, "no such directory: #{missing.join(", ")}") unless missing.empty?
60
+ end
61
+
62
+ def distinct(directories)
63
+ pairs = directories.map { [it, File.realpath(it)] }.uniq(&:last)
64
+ pairs.filter_map { |dir, path| dir unless nested?(path, pairs) }
65
+ end
66
+
67
+ def nested?(path, pairs) = pairs.any? { path.start_with?("#{it.last}/") }
68
+
55
69
  def config?(directory)
56
70
  ["config/application.rb", "../config/application.rb"].any? { File.exist?(File.expand_path(it, directory)) }
57
71
  end
@@ -0,0 +1,48 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Report::Columns
4
+ CAP = 48
5
+ HEAD = 23
6
+ GAP = " "
7
+ NUMBER = /\A-?\d+(?:\.\d+)?\z/
8
+
9
+ def initialize(headers, rows, io: $stdout)
10
+ @headers, *@rows = [headers, *rows].map { |cells| cells.map { clip(it) } }
11
+ @io = io
12
+ end
13
+
14
+ def print
15
+ @io.puts(header)
16
+ @io.puts(rule)
17
+ body
18
+ end
19
+
20
+ def body = @rows.each { @io.puts(line(it)) }
21
+
22
+ def header = line(@headers)
23
+
24
+ def rule = "-" * [header, *@rows.map { line(it) }].map(&:length).max
25
+
26
+ private
27
+
28
+ def line(cells) = cells.map.with_index { |cell, column| pad(cell, column) }.join(GAP).rstrip
29
+
30
+ def pad(cell, column)
31
+ width = widths[column]
32
+ numeric[column] ? cell.rjust(width) : cell.ljust(width)
33
+ end
34
+
35
+ def widths = @widths ||= @headers.each_index.map { |column| down(column).map(&:length).max }
36
+
37
+ def numeric = @numeric ||= @headers.each_index.map { |column| @rows.any? && counted?(column) }
38
+
39
+ def counted?(column) = @rows.all? { NUMBER.match?(it[column]) }
40
+
41
+ def down(column) = [@headers[column], *@rows.map { it[column] }]
42
+
43
+ def clip(cell)
44
+ text = cell.to_s
45
+ return text if text.length <= CAP
46
+ "#{text[0, HEAD]}…#{text[(HEAD + 1 - CAP)..]}"
47
+ end
48
+ end
@@ -2,35 +2,31 @@
2
2
 
3
3
  class Hashira::Report::ComplexityTable
4
4
  TOP = 10
5
- METHOD_ROW = "%-44s %4s %6s %s"
6
- CLASS_ROW = "%-32s %5s %8s %6s"
5
+ METHOD_HEADERS = %w[method Cog Calls Loc].freeze
6
+ CLASS_HEADERS = %w[class Cog Methods Peak].freeze
7
7
  METHOD_TITLE = "Cognitive complexity — worst methods (Cog = how hard to read, Calls = message sends)"
8
8
  CLASS_TITLE = "Per-class rollup (Cog total survives extract-method; Peak is the worst method it hides)"
9
9
 
10
- def initialize(complexity, io: $stdout)
10
+ def initialize(complexity, top: TOP, io: $stdout)
11
11
  @complexity = complexity
12
+ @top = top
12
13
  @io = io
13
14
  end
14
15
 
15
16
  def print
16
- section(@complexity.ranked, METHOD_ROW, %w[method Cog Calls Loc], METHOD_TITLE)
17
- section(@complexity.classes, CLASS_ROW, %w[class Cog Methods Peak], CLASS_TITLE)
17
+ section(@complexity.ranked, METHOD_HEADERS, METHOD_TITLE)
18
+ section(@complexity.classes, CLASS_HEADERS, CLASS_TITLE)
18
19
  end
19
20
 
20
21
  private
21
22
 
22
- def section(scores, row_format, columns, title)
23
- heading(row_format, columns, title)
24
- ranked(scores).each { @io.puts(format(row_format, *it.cells)) }
25
- @io.puts
26
- end
27
-
28
- def heading(row_format, columns, title)
29
- header = format(row_format, *columns)
23
+ def section(scores, headers, title)
24
+ rows = ranked(scores)
25
+ return if rows.empty?
30
26
  @io.puts("#{title}:\n\n")
31
- @io.puts(header)
32
- @io.puts("-" * header.length)
27
+ Hashira::Report::Columns.new(headers, rows.map(&:cells), io: @io).print
28
+ @io.puts
33
29
  end
34
30
 
35
- def ranked(scores) = scores.select { it.cognitive.positive? }.first(TOP)
31
+ def ranked(scores) = scores.select { it.cognitive.positive? }.first(@top)
36
32
  end
@@ -9,6 +9,7 @@ class Hashira::Report::DependencyMap
9
9
  def print
10
10
  @io.puts("Dependencies (DependsUpon(refs) -> | <- UsedBy):")
11
11
  @graph.packages.sort.each { @io.puts(row(it)) }
12
+ @io.puts
12
13
  end
13
14
 
14
15
  private
@@ -2,32 +2,24 @@
2
2
 
3
3
  class Hashira::Report::HotspotTable
4
4
  TOP = 10
5
- ROW = "%-46s %5s %5s %6s %7s"
5
+ HEADERS = %w[file Cog Dup Churn Rank].freeze
6
6
 
7
- def initialize(hotspots, io: $stdout)
7
+ def initialize(hotspots, top: TOP, io: $stdout)
8
8
  @hotspots = hotspots
9
+ @top = top
9
10
  @io = io
10
11
  end
11
12
 
12
13
  def print
13
14
  return if ranked.empty?
14
- heading
15
- rows
15
+ @io.puts("Hotspots — cost × churn (where refactoring pays the most):\n\n")
16
+ Hashira::Report::Columns.new(HEADERS, ranked.map(&:cells), io: @io).print
16
17
  legend
17
18
  end
18
19
 
19
20
  private
20
21
 
21
- def ranked = @ranked ||= @hotspots.files.first(TOP)
22
-
23
- def rows = ranked.each { @io.puts(format(ROW, *it.cells)) }
24
-
25
- def heading
26
- @io.puts("Hotspots — cost × churn (where refactoring pays the most):\n\n")
27
- header = format(ROW, *%w[file Cog Dup Churn Rank])
28
- @io.puts(header)
29
- @io.puts("-" * header.length)
30
- end
22
+ def ranked = @ranked ||= @hotspots.files.first(@top)
31
23
 
32
24
  def legend
33
25
  @io.puts("\nLegend: Cog cognitive complexity, Dup mass of the clones the file carries,")
@@ -8,14 +8,21 @@ class Hashira::Report::Json
8
8
  @io = io
9
9
  end
10
10
 
11
+ SCHEMA = 1
12
+
11
13
  def print
12
- @io.puts(JSON.pretty_generate(payload))
14
+ @io.puts(@view.compact ? JSON.generate(payload) : JSON.pretty_generate(payload))
13
15
  0
14
16
  end
15
17
 
16
18
  private
17
19
 
18
- def payload = base.merge(coupling).merge(sections.compact)
20
+ def payload = about.merge(base).merge(coupling).merge(sections.compact)
21
+
22
+ def about
23
+ project = @view.project
24
+ { version: SCHEMA, packaging: @view.graph&.packaging, targets: project.directories, files: project.files.size }
25
+ end
19
26
 
20
27
  def base = { findings: @view.findings.all.map { rendered(it) }, accepted: accepted }
21
28