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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +98 -0
- data/README.md +131 -56
- 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 +15 -19
- 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/smell_phrases.rb +6 -0
- data/lib/hashira/report/text.rb +24 -9
- data/lib/hashira/report/view.rb +4 -1
- data/lib/hashira/smells/boundary_sprawl.rb +43 -0
- data/lib/hashira/smells/census.rb +4 -1
- data/lib/hashira/smells/contexts.rb +1 -1
- data/lib/hashira/smells/feature_envy.rb +3 -1
- data/lib/hashira/smells/foreign.rb +131 -0
- data/lib/hashira/smells/ownership.rb +55 -0
- data/lib/hashira/smells/report.rb +10 -2
- data/lib/hashira/trees.rb +22 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +6 -0
- metadata +17 -6
data/lib/hashira/ci/ratchet.rb
CHANGED
|
@@ -1,30 +1,53 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::CI::Ratchet
|
|
4
|
-
def initialize(graph, findings,
|
|
4
|
+
def initialize(graph, findings, baseline, io: $stdout)
|
|
5
5
|
@graph = graph
|
|
6
6
|
@findings = findings
|
|
7
|
-
@baseline =
|
|
7
|
+
@baseline = baseline
|
|
8
8
|
@io = io
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def update
|
|
12
|
-
@baseline.write(edges,
|
|
13
|
-
@io.puts("Baseline updated: #{edges.size} edges, #{
|
|
14
|
-
|
|
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
|
-
|
|
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
|
|
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(
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
@@ -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
|
@@ -9,39 +9,41 @@ class Hashira::Pipeline
|
|
|
9
9
|
|
|
10
10
|
STRUCTURAL = Hashira::Coupling::Report::RULES.map { it::KIND }.freeze
|
|
11
11
|
|
|
12
|
-
SMELLS =
|
|
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
|
-
@
|
|
18
|
-
@coupling = Hashira::Coupling::Report.new(project, @
|
|
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
|
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
|