hashira 0.5.1 → 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 (52) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +98 -0
  3. data/README.md +131 -56
  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 +15 -19
  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/smell_phrases.rb +6 -0
  40. data/lib/hashira/report/text.rb +24 -9
  41. data/lib/hashira/report/view.rb +4 -1
  42. data/lib/hashira/smells/boundary_sprawl.rb +43 -0
  43. data/lib/hashira/smells/census.rb +4 -1
  44. data/lib/hashira/smells/contexts.rb +1 -1
  45. data/lib/hashira/smells/feature_envy.rb +3 -1
  46. data/lib/hashira/smells/foreign.rb +131 -0
  47. data/lib/hashira/smells/ownership.rb +55 -0
  48. data/lib/hashira/smells/report.rb +10 -2
  49. data/lib/hashira/trees.rb +22 -0
  50. data/lib/hashira/version.rb +1 -1
  51. data/lib/hashira.rb +6 -0
  52. metadata +17 -6
@@ -1,30 +1,53 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::CI::Ratchet
4
- def initialize(graph, findings, baseline_path, io: $stdout)
4
+ def initialize(graph, findings, baseline, io: $stdout)
5
5
  @graph = graph
6
6
  @findings = findings
7
- @baseline = Hashira::CI::Baseline.load(baseline_path)
7
+ @baseline = baseline
8
8
  @io = io
9
9
  end
10
10
 
11
11
  def update
12
- @baseline.write(edges, digests, packaging:)
13
- @io.puts("Baseline updated: #{edges.size} edges, #{digests.size} findings.")
14
- 0
12
+ @baseline.write(edges, scored, packaging:)
13
+ @io.puts("Baseline updated: #{edges.size} edges, #{recorded}.")
14
+ Hashira::CI::Status::CLEAN
15
15
  end
16
16
 
17
17
  def check = Hashira::CI::RatchetReport.new(@graph, @findings, io: @io).print(drift, delta)
18
18
 
19
19
  def blocker
20
20
  return "no baseline at #{@baseline.path} — run --update-baseline first" unless @baseline.exist?
21
- mismatch unless @baseline.packaging == packaging
21
+ drifted.first
22
22
  end
23
23
 
24
24
  private
25
25
 
26
+ def drifted = [(mismatch unless @baseline.packaging == packaging), *narrowed(@baseline.wanted)].compact
27
+
28
+ def narrowed(wanted)
29
+ [
30
+ widened("the analyzers", @baseline.analyzers, wanted[:analyzers]),
31
+ widened("the directories", @baseline.targets, wanted[:targets])
32
+ ]
33
+ end
34
+
35
+ def widened(flag, was, now)
36
+ return if was == now
37
+ phrase(flag, was, now)
38
+ end
39
+
40
+ def phrase(flag, was, now)
41
+ "baseline #{@baseline.path} was recorded with #{flag} #{was.join(", ")}, but this run " \
42
+ "uses #{now.join(", ")} — rerun the recorded way, or refresh it with --update-baseline"
43
+ end
44
+
26
45
  def packaging = @graph.packaging.to_s
27
46
 
47
+ def recorded = collapsed(@findings.size, scored.size)
48
+
49
+ def collapsed(total, keys) = keys == total ? "#{total} findings" : "#{total} findings (#{keys} distinct)"
50
+
28
51
  def mismatch
29
52
  recorded = @baseline.packaging
30
53
  "baseline #{@baseline.path} was recorded with --package-by #{recorded}, but this run " \
@@ -33,13 +56,15 @@ class Hashira::CI::Ratchet
33
56
 
34
57
  def edges = @graph.edges.map(&:to_s)
35
58
 
36
- def digests = @findings.map(&:signature).uniq.sort
59
+ def scored
60
+ @scored ||= @findings.group_by(&:signature).sort.to_h { |key, group| [key, group.filter_map(&:magnitude).max] }
61
+ end
37
62
 
38
63
  def drift = Hashira::CI::Diff.new(added: fresh, removed: @baseline.edges - edges)
39
64
 
40
65
  def fresh = @graph.edges.reject { @baseline.edges.include?(it.to_s) }
41
66
 
42
67
  def delta
43
- Hashira::CI::Diff.between(digests, @baseline.findings) if @baseline.findings?
68
+ Hashira::CI::Diff.between(scored, @baseline.findings) if @baseline.findings?
44
69
  end
45
70
  end
@@ -11,7 +11,7 @@ class Hashira::CI::RatchetReport
11
11
  return unchanged if quiet?(edges, findings)
12
12
  details(edges, findings)
13
13
  advice(edges, findings)
14
- 1
14
+ verdict(edges, findings)
15
15
  end
16
16
 
17
17
  private
@@ -20,9 +20,11 @@ class Hashira::CI::RatchetReport
20
20
 
21
21
  def unchanged
22
22
  @io.puts("Ratchet OK: #{@graph.edges.size} edges, #{@findings.size} findings, unchanged.")
23
- 0
23
+ Hashira::CI::Status::CLEAN
24
24
  end
25
25
 
26
+ def verdict(*diffs) = diffs.compact.any?(&:worse?) ? Hashira::CI::Status::WORSE : Hashira::CI::Status::BETTER
27
+
26
28
  def details(edges, findings)
27
29
  Hashira::CI::EdgeDiffReport.new(@graph, io: @io).print(edges)
28
30
  Hashira::CI::FindingDiffReport.new(@findings, io: @io).print(findings) if findings
@@ -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
@@ -9,39 +9,41 @@ class Hashira::Pipeline
9
9
 
10
10
  STRUCTURAL = Hashira::Coupling::Report::RULES.map { it::KIND }.freeze
11
11
 
12
- SMELLS = Hashira::Smells::Report::CHECKS.map(&:kind).sort.freeze
12
+ SMELLS = (
13
+ Hashira::Smells::Report::CHECKS.map(&:kind) + [Hashira::Smells::BoundarySprawl::KIND]
14
+ ).sort.freeze
13
15
 
14
16
  def initialize(project, enabled: ANALYZERS, packaging: :auto)
15
17
  @project = project
16
18
  @enabled = enabled
17
- @trees = project.files.to_h { [it, parse(it)] }
18
- @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))
19
21
  end
20
22
 
21
23
  attr_reader :project
22
24
 
25
+ def unparsed = @parsed.unparsed
26
+
23
27
  def graph = @coupling.graph
24
28
 
25
- def complexity
26
- @complexity ||= Hashira::Complexity::Scores.new(@project, @trees) if enabled?(:complexity)
27
- end
29
+ def complexity = @complexity ||= analyzed(:complexity, Hashira::Complexity::Scores)
28
30
 
29
- def duplication
30
- @duplication ||= Hashira::Duplication::Clones.new(@project, @trees, churn) if enabled?(:duplication)
31
- end
31
+ def duplication = @duplication ||= analyzed(:duplication, Hashira::Duplication::Clones, churn)
32
32
 
33
- def smells
34
- @smells ||= Hashira::Smells::Report.new(@project, @trees) if enabled?(:smells)
35
- end
33
+ def smells = @smells ||= analyzed(:smells, Hashira::Smells::Report)
36
34
 
37
35
  def hotspots
38
36
  @hotspots ||= Hashira::Hotspots::Rollup.new(complexity, duplication, churn) if complexity || duplication
39
37
  end
40
38
 
41
- def churn = @churn ||= Hashira::Churn.scan
39
+ def churn = @churn ||= Hashira::Churn.scan(@project.directories.first)
42
40
 
43
41
  def enabled?(analyzer) = @enabled.include?(analyzer)
44
42
 
43
+ def analyzed(name, kind, *extra)
44
+ kind.new(@project, @parsed.all, *extra) if enabled?(name)
45
+ end
46
+
45
47
  def findings = structural + listed(complexity) + listed(duplication) + listed(smells)
46
48
 
47
49
  private
@@ -54,10 +56,4 @@ class Hashira::Pipeline
54
56
  def structural = enabled?(:coupling) ? @coupling.findings : []
55
57
 
56
58
  def listed(analyzer) = analyzer ? analyzer.findings : []
57
-
58
- def parse(path)
59
- Prism.parse_file(path).value
60
- rescue SystemCallError => error
61
- raise(Hashira::Error, "cannot read #{path} (#{error.message})")
62
- end
63
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