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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +68 -0
- data/README.md +119 -53
- data/lib/hashira/analysis/finding.rb +4 -0
- data/lib/hashira/churn.rb +10 -2
- data/lib/hashira/ci/accepted.rb +1 -4
- data/lib/hashira/ci/baseline.rb +28 -5
- data/lib/hashira/ci/diff.rb +15 -4
- data/lib/hashira/ci/finding_diff_report.rb +10 -0
- data/lib/hashira/ci/gate.rb +10 -2
- data/lib/hashira/ci/improvement.rb +1 -1
- data/lib/hashira/ci/ratchet.rb +33 -8
- data/lib/hashira/ci/ratchet_report.rb +4 -2
- data/lib/hashira/ci/scope.rb +11 -0
- data/lib/hashira/ci/status.rb +7 -0
- data/lib/hashira/cli/arguments.rb +6 -1
- data/lib/hashira/cli/fail_on.rb +12 -2
- data/lib/hashira/cli/flags.rb +13 -0
- data/lib/hashira/cli/needs.rb +34 -0
- data/lib/hashira/cli/options.rb +14 -3
- data/lib/hashira/cli/run.rb +32 -5
- data/lib/hashira/cli/top.rb +13 -0
- data/lib/hashira/cli/usage.rb +5 -1
- data/lib/hashira/cli.rb +24 -5
- data/lib/hashira/coupling/metric.rb +7 -1
- data/lib/hashira/diagram/dot.rb +9 -6
- data/lib/hashira/diagram/mermaid.rb +11 -8
- data/lib/hashira/diagram/source.rb +5 -2
- data/lib/hashira/pipeline.rb +12 -18
- data/lib/hashira/project.rb +19 -5
- data/lib/hashira/report/columns.rb +48 -0
- data/lib/hashira/report/complexity_table.rb +12 -16
- data/lib/hashira/report/dependency_map.rb +1 -0
- data/lib/hashira/report/hotspot_table.rb +6 -14
- data/lib/hashira/report/json.rb +9 -2
- data/lib/hashira/report/metrics_table.rb +19 -20
- data/lib/hashira/report/notices.rb +37 -0
- data/lib/hashira/report/phrases.rb +2 -0
- data/lib/hashira/report/text.rb +24 -9
- data/lib/hashira/report/view.rb +4 -1
- data/lib/hashira/trees.rb +22 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +6 -0
- metadata +8 -1
|
@@ -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
|
-
|
|
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
|
|
data/lib/hashira/cli/fail_on.rb
CHANGED
|
@@ -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 []
|
|
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(", ")})")
|
data/lib/hashira/cli/flags.rb
CHANGED
|
@@ -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
|
data/lib/hashira/cli/options.rb
CHANGED
|
@@ -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(
|
|
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::
|
|
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
|
data/lib/hashira/cli/run.rb
CHANGED
|
@@ -14,9 +14,20 @@ class Hashira::CLI::Run
|
|
|
14
14
|
|
|
15
15
|
def graph = @pipeline.graph
|
|
16
16
|
|
|
17
|
-
def findings = @findings ||=
|
|
17
|
+
def findings = @findings ||= accepted.screen(@pipeline.findings)
|
|
18
18
|
|
|
19
|
-
def
|
|
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)
|
|
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
|
|
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
|
data/lib/hashira/cli/usage.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
data/lib/hashira/diagram/dot.rb
CHANGED
|
@@ -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
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
@
|
|
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
|
|
16
|
-
|
|
17
|
-
|
|
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
|
-
|
|
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
|
data/lib/hashira/pipeline.rb
CHANGED
|
@@ -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
|
-
@
|
|
20
|
-
@coupling = Hashira::Coupling::Report.new(project, @
|
|
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
|
data/lib/hashira/project.rb
CHANGED
|
@@ -8,8 +8,8 @@ class Hashira::Project
|
|
|
8
8
|
end
|
|
9
9
|
|
|
10
10
|
def self.defaults
|
|
11
|
-
|
|
12
|
-
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
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
|
-
|
|
6
|
-
|
|
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,
|
|
17
|
-
section(@complexity.classes,
|
|
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,
|
|
23
|
-
|
|
24
|
-
|
|
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.
|
|
32
|
-
@io.puts
|
|
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(
|
|
31
|
+
def ranked(scores) = scores.select { it.cognitive.positive? }.first(@top)
|
|
36
32
|
end
|
|
@@ -2,32 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
class Hashira::Report::HotspotTable
|
|
4
4
|
TOP = 10
|
|
5
|
-
|
|
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
|
-
|
|
15
|
-
|
|
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(
|
|
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,")
|
data/lib/hashira/report/json.rb
CHANGED
|
@@ -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
|
|