hashira 0.1.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 +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +98 -0
- data/exe/hashira +9 -0
- data/lib/hashira/analysis/census.rb +41 -0
- data/lib/hashira/analysis/cycle_findings.rb +33 -0
- data/lib/hashira/analysis/cycle_search.rb +44 -0
- data/lib/hashira/analysis/definitions.rb +27 -0
- data/lib/hashira/analysis/edge.rb +9 -0
- data/lib/hashira/analysis/edge_map.rb +36 -0
- data/lib/hashira/analysis/finding.rb +11 -0
- data/lib/hashira/analysis/graph.rb +56 -0
- data/lib/hashira/analysis/metric.rb +14 -0
- data/lib/hashira/analysis/node_walk.rb +14 -0
- data/lib/hashira/analysis/references.rb +39 -0
- data/lib/hashira/analysis/root_namespace.rb +14 -0
- data/lib/hashira/analysis/rule.rb +22 -0
- data/lib/hashira/analysis/sdp_check.rb +18 -0
- data/lib/hashira/analysis/sdp_violation_findings.rb +27 -0
- data/lib/hashira/analysis/syntax.rb +27 -0
- data/lib/hashira/analysis/type_walk.rb +26 -0
- data/lib/hashira/ci/accepted.rb +44 -0
- data/lib/hashira/ci/edge_diff_report.rb +47 -0
- data/lib/hashira/ci/gate.rb +29 -0
- data/lib/hashira/ci/ratchet.rb +44 -0
- data/lib/hashira/cli/command_line.rb +86 -0
- data/lib/hashira/cli/fail_on.rb +26 -0
- data/lib/hashira/cli/options.rb +9 -0
- data/lib/hashira/cli/run.rb +38 -0
- data/lib/hashira/cli/usage.rb +40 -0
- data/lib/hashira/cli.rb +26 -0
- data/lib/hashira/diagram/dot.rb +16 -0
- data/lib/hashira/diagram/mermaid.rb +23 -0
- data/lib/hashira/diagram/renderer.rb +20 -0
- data/lib/hashira/error.rb +5 -0
- data/lib/hashira/pipeline.rb +28 -0
- data/lib/hashira/project.rb +47 -0
- data/lib/hashira/report/dependency_map.rb +27 -0
- data/lib/hashira/report/finding_lines.rb +26 -0
- data/lib/hashira/report/json.rb +41 -0
- data/lib/hashira/report/metrics_table.rb +38 -0
- data/lib/hashira/report/text.rb +58 -0
- data/lib/hashira/version.rb +5 -0
- data/lib/hashira.rb +41 -0
- metadata +91 -0
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module CI
|
|
5
|
+
class EdgeDiffReport
|
|
6
|
+
def initialize(graph, io: $stdout)
|
|
7
|
+
@graph = graph
|
|
8
|
+
@io = io
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def print(added, removed)
|
|
12
|
+
return print_unchanged if added.empty? && removed.empty?
|
|
13
|
+
|
|
14
|
+
print_added(added)
|
|
15
|
+
print_removed(removed)
|
|
16
|
+
1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def print_unchanged
|
|
22
|
+
@io.puts "Ratchet OK: #{@graph.edge_list.size} edges, unchanged."
|
|
23
|
+
0
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def print_added(added)
|
|
27
|
+
return if added.empty?
|
|
28
|
+
|
|
29
|
+
added.each { print_edge(_1) }
|
|
30
|
+
@io.puts "\nRatchet FAILED. Either decouple, or if the new edge is a deliberate"
|
|
31
|
+
@io.puts "design decision, update the baseline and say why in the commit."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def print_edge(edge)
|
|
35
|
+
@io.puts "NEW EDGE #{edge} — introduced by:"
|
|
36
|
+
@graph.evidence_for(edge.from, edge.to).to_a.sort.each { @io.puts " · #{_1}" }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def print_removed(removed)
|
|
40
|
+
return if removed.empty?
|
|
41
|
+
|
|
42
|
+
@io.puts "Edges removed (improvement!): #{removed.join(", ")}"
|
|
43
|
+
@io.puts "Lock it in: hashira --update-baseline"
|
|
44
|
+
end
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module CI
|
|
5
|
+
class Gate
|
|
6
|
+
def initialize(findings, kinds, io: $stdout)
|
|
7
|
+
@findings = findings
|
|
8
|
+
@kinds = kinds
|
|
9
|
+
@io = io
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def check
|
|
13
|
+
offending = @findings.all.select { @kinds.include?(_1.kind) }
|
|
14
|
+
return report_clean if offending.empty?
|
|
15
|
+
|
|
16
|
+
offending.each { Report::FindingLines.new(_1, io: @io).print }
|
|
17
|
+
@io.puts "\nGate FAILED: #{offending.size} finding(s) of kind #{@kinds.join(", ")}."
|
|
18
|
+
1
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def report_clean
|
|
24
|
+
@io.puts "Gate OK: no findings of kind #{@kinds.join(", ")}."
|
|
25
|
+
0
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module CI
|
|
7
|
+
class Ratchet
|
|
8
|
+
def initialize(graph, baseline_path, io: $stdout)
|
|
9
|
+
@graph = graph
|
|
10
|
+
@baseline_path = baseline_path
|
|
11
|
+
@io = io
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
SCHEMA_VERSION = 1
|
|
15
|
+
|
|
16
|
+
def update
|
|
17
|
+
edges = @graph.edge_list.map(&:to_s)
|
|
18
|
+
File.write(@baseline_path, JSON.pretty_generate(payload(edges)) << "\n")
|
|
19
|
+
@io.puts "Baseline updated: #{edges.size} edges."
|
|
20
|
+
0
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def check
|
|
24
|
+
added, removed = diff(@graph.edge_list)
|
|
25
|
+
EdgeDiffReport.new(@graph, io: @io).print(added, removed)
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
private
|
|
29
|
+
|
|
30
|
+
def payload(edges)
|
|
31
|
+
accepted = Accepted.load(File.exist?(@baseline_path) ? @baseline_path : nil).entries
|
|
32
|
+
base = { version: SCHEMA_VERSION, edges: }
|
|
33
|
+
accepted.empty? ? base : base.merge(accepted:)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def diff(edges)
|
|
37
|
+
raise Error, "no baseline at #{@baseline_path} — run --update-baseline first" unless File.exist?(@baseline_path)
|
|
38
|
+
|
|
39
|
+
baseline = JSON.parse(File.read(@baseline_path)).fetch("edges")
|
|
40
|
+
[edges.reject { baseline.include?(_1.to_s) }, baseline - edges.map(&:to_s)]
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
class CLI
|
|
5
|
+
class CommandLine
|
|
6
|
+
DEFAULT_BASELINE = "hashira_baseline.json"
|
|
7
|
+
|
|
8
|
+
FORMATS = %w[text json dot mermaid].freeze
|
|
9
|
+
|
|
10
|
+
CI_FLAGS = { "--update-baseline" => :update_baseline, "--ratchet" => :ratchet }.freeze
|
|
11
|
+
|
|
12
|
+
def initialize(argv)
|
|
13
|
+
@arguments = argv.dup
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def options = usage_options || parsed_options
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def usage_options
|
|
21
|
+
return usage(:help) if delete("--help") || delete("-h")
|
|
22
|
+
|
|
23
|
+
usage(:version) if delete("--version")
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def usage(mode) = Options.new(directories: [], mode:, baseline: nil, fail_on: [])
|
|
27
|
+
|
|
28
|
+
def parsed_options
|
|
29
|
+
values = flag_values
|
|
30
|
+
mode = parse_mode(values[:fail_on])
|
|
31
|
+
reject_unknown_flags
|
|
32
|
+
Options.new(directories: @arguments, mode:, **values)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def flag_values
|
|
36
|
+
{ fail_on: FailOn.parse(take_value("--fail-on")),
|
|
37
|
+
baseline: take_value("--baseline") || DEFAULT_BASELINE }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def parse_mode(fail_on)
|
|
41
|
+
requested = requested_modes(fail_on).uniq(&:last)
|
|
42
|
+
raise Error, "conflicting options: #{requested.map(&:first).join(" and ")}" if requested.size > 1
|
|
43
|
+
|
|
44
|
+
requested.dig(0, 1) || :text
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def requested_modes(fail_on)
|
|
48
|
+
ci = CI_FLAGS.filter_map { |flag, mode| [flag, mode] if delete(flag) }
|
|
49
|
+
ci + fail_on_mode(fail_on) + format_modes
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def fail_on_mode(fail_on) = fail_on.empty? ? [] : [["--fail-on", :fail_on]]
|
|
53
|
+
|
|
54
|
+
def format_modes
|
|
55
|
+
format = take_format
|
|
56
|
+
modes = format ? [["--format #{format}", format.to_sym]] : []
|
|
57
|
+
delete("--json") ? modes + [["--json", :json]] : modes
|
|
58
|
+
end
|
|
59
|
+
|
|
60
|
+
def take_format
|
|
61
|
+
format = take_value("--format")
|
|
62
|
+
return format unless format
|
|
63
|
+
raise Error, "unknown --format #{format.inspect} (use: #{FORMATS.join(", ")})" unless FORMATS.include?(format)
|
|
64
|
+
|
|
65
|
+
format
|
|
66
|
+
end
|
|
67
|
+
|
|
68
|
+
def take_value(flag)
|
|
69
|
+
position = @arguments.index(flag)
|
|
70
|
+
return nil unless position
|
|
71
|
+
|
|
72
|
+
_flag, value = @arguments.slice!(position, 2)
|
|
73
|
+
raise Error, "#{flag} needs a value" unless value
|
|
74
|
+
|
|
75
|
+
value
|
|
76
|
+
end
|
|
77
|
+
|
|
78
|
+
def delete(flag) = @arguments.delete(flag)
|
|
79
|
+
|
|
80
|
+
def reject_unknown_flags
|
|
81
|
+
stray = @arguments.find { _1.start_with?("-") }
|
|
82
|
+
raise Error, "unknown option #{stray}" if stray
|
|
83
|
+
end
|
|
84
|
+
end
|
|
85
|
+
end
|
|
86
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
class CLI
|
|
5
|
+
module FailOn
|
|
6
|
+
KINDS = {
|
|
7
|
+
"cycles" => "cycle", "cycle" => "cycle",
|
|
8
|
+
"sdp" => "sdp_violation", "sdp_violation" => "sdp_violation"
|
|
9
|
+
}.freeze
|
|
10
|
+
|
|
11
|
+
module_function
|
|
12
|
+
|
|
13
|
+
def parse(list)
|
|
14
|
+
return [] unless list
|
|
15
|
+
|
|
16
|
+
list.split(",").map { kind(_1.strip) }.uniq
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def kind(name)
|
|
20
|
+
KINDS.fetch(name) do
|
|
21
|
+
raise Error, "unknown --fail-on kind #{name.inspect} (use: #{KINDS.keys.join(", ")})"
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
class CLI
|
|
5
|
+
class Run
|
|
6
|
+
MODES = { update_baseline: :update_baseline, ratchet: :check_ratchet,
|
|
7
|
+
fail_on: :check_gate, json: :print_json,
|
|
8
|
+
dot: :print_diagram, mermaid: :print_diagram }.freeze
|
|
9
|
+
|
|
10
|
+
def initialize(pipeline, options)
|
|
11
|
+
@pipeline = pipeline
|
|
12
|
+
@options = options
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def exit_code = send(MODES.fetch(@options.mode, :print_text))
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def graph = @pipeline.graph
|
|
20
|
+
|
|
21
|
+
def ratchet = CI::Ratchet.new(graph, @options.baseline)
|
|
22
|
+
|
|
23
|
+
def update_baseline = ratchet.update
|
|
24
|
+
|
|
25
|
+
def check_ratchet = ratchet.check
|
|
26
|
+
|
|
27
|
+
def findings = @findings ||= CI::Accepted.load(@options.baseline).screen(@pipeline.findings)
|
|
28
|
+
|
|
29
|
+
def check_gate = CI::Gate.new(findings, @options.fail_on).check
|
|
30
|
+
|
|
31
|
+
def print_json = Report::Json.new(graph, findings).print
|
|
32
|
+
|
|
33
|
+
def print_diagram = Diagram::Renderer.new(graph, @options.mode).display
|
|
34
|
+
|
|
35
|
+
def print_text = Report::Text.new(@pipeline.project, graph, findings).print
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
class CLI
|
|
5
|
+
module Usage
|
|
6
|
+
TEXT = <<~HELP
|
|
7
|
+
Usage: hashira [DIRECTORY ...] [options]
|
|
8
|
+
|
|
9
|
+
Package coupling metrics for Ruby, via Prism. With no directory,
|
|
10
|
+
auto-detects lib/<gem>.
|
|
11
|
+
|
|
12
|
+
Options:
|
|
13
|
+
--format FORMAT text (default), json, dot, or mermaid
|
|
14
|
+
--json shorthand for --format json
|
|
15
|
+
--fail-on KINDS exit 1 if findings exist; comma-separated
|
|
16
|
+
kinds: cycles, sdp
|
|
17
|
+
--ratchet fail when edges appear that the baseline lacks
|
|
18
|
+
--update-baseline write the current edge set as the baseline
|
|
19
|
+
--baseline PATH baseline file (default: hashira_baseline.json)
|
|
20
|
+
-h, --help print this help
|
|
21
|
+
--version print the version
|
|
22
|
+
|
|
23
|
+
Findings accepted by design can be recorded in the baseline as
|
|
24
|
+
"accepted": [{"kind": "...", "package": "...", "reason": "..."}]
|
|
25
|
+
— they leave reports and gates, keeping a one-line reminder each.
|
|
26
|
+
HELP
|
|
27
|
+
|
|
28
|
+
module_function
|
|
29
|
+
|
|
30
|
+
def help = emit(TEXT)
|
|
31
|
+
|
|
32
|
+
def version = emit("hashira #{VERSION}")
|
|
33
|
+
|
|
34
|
+
def emit(output)
|
|
35
|
+
puts output
|
|
36
|
+
0
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
data/lib/hashira/cli.rb
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
class CLI
|
|
5
|
+
def self.run(argv)
|
|
6
|
+
options = Options.parse(argv)
|
|
7
|
+
usage?(options) ? Usage.public_send(options.mode) : new(options).run
|
|
8
|
+
rescue Error => error
|
|
9
|
+
report_failure(error)
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def self.usage?(options) = %i[help version].include?(options.mode)
|
|
13
|
+
|
|
14
|
+
def self.report_failure(error)
|
|
15
|
+
warn "hashira: #{error.message}"
|
|
16
|
+
1
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def initialize(options)
|
|
20
|
+
@options = options
|
|
21
|
+
@pipeline = Pipeline.new(Project.detect(options.directories))
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def run = Run.new(@pipeline, @options).exit_code
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Diagram
|
|
5
|
+
class Dot
|
|
6
|
+
def initialize(edges)
|
|
7
|
+
@edges = edges
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def source
|
|
11
|
+
lines = @edges.map { |from, to, weight| %( "#{from}" -> "#{to}" [label="#{weight}"];) }
|
|
12
|
+
"digraph hashira {\n rankdir=LR;\n#{lines.join("\n")}\n}"
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Diagram
|
|
5
|
+
class Mermaid
|
|
6
|
+
def initialize(edges)
|
|
7
|
+
@edges = edges
|
|
8
|
+
@nodes = {}
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def source
|
|
12
|
+
lines = @edges.map { |from, to, weight| " #{node(from)} -->|#{weight}| #{node(to)}" }
|
|
13
|
+
"graph LR\n#{lines.join("\n")}"
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def node(package)
|
|
19
|
+
@nodes[package] ||= "#{package.gsub(/\W/, "_")}[\"#{package}\"]"
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Diagram
|
|
5
|
+
class Renderer
|
|
6
|
+
def initialize(graph, format, io: $stdout)
|
|
7
|
+
@graph = graph
|
|
8
|
+
@format = format
|
|
9
|
+
@io = io
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def display
|
|
13
|
+
edges = @graph.weighted_edges
|
|
14
|
+
renderer = @format == :dot ? Dot.new(edges) : Mermaid.new(edges)
|
|
15
|
+
@io.puts renderer.source
|
|
16
|
+
0
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
class Pipeline
|
|
7
|
+
def initialize(project)
|
|
8
|
+
@project = project
|
|
9
|
+
trees = project.files.to_h { [_1, parse(_1)] }
|
|
10
|
+
@census = Analysis::Census.new(project, trees)
|
|
11
|
+
@graph = Analysis::Graph.new(project, trees, @census)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :project, :graph
|
|
15
|
+
|
|
16
|
+
RULES = [Analysis::CycleFindings, Analysis::SdpViolationFindings].freeze
|
|
17
|
+
|
|
18
|
+
def findings = RULES.flat_map { _1.new(@project, @graph).list }
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def parse(path)
|
|
23
|
+
Prism.parse_file(path).value
|
|
24
|
+
rescue SystemCallError => error
|
|
25
|
+
raise Error, "cannot read #{path} (#{error.message})"
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
class Project
|
|
5
|
+
ROOT_PACKAGE = "(root)"
|
|
6
|
+
|
|
7
|
+
def self.detect(directories)
|
|
8
|
+
return new(directories) unless directories.empty?
|
|
9
|
+
|
|
10
|
+
subdirectories = Dir["lib/*/"].map { File.basename(_1) }
|
|
11
|
+
raise Error, "no lib/ directory here — pass the source directory explicitly" if subdirectories.empty?
|
|
12
|
+
|
|
13
|
+
new(subdirectories.size == 1 ? ["lib/#{subdirectories.first}"] : ["lib"])
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def initialize(directories)
|
|
17
|
+
missing = directories.reject { Dir.exist?(_1) }
|
|
18
|
+
raise Error, "no such directory: #{missing.join(", ")}" unless missing.empty?
|
|
19
|
+
|
|
20
|
+
@directories = directories.map { _1.delete_suffix("/") }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
attr_reader :directories
|
|
24
|
+
|
|
25
|
+
def files = @directories.flat_map { Dir["#{_1}/**/*.rb"] }.sort
|
|
26
|
+
|
|
27
|
+
def package_for(path)
|
|
28
|
+
first, rest = relative(path).delete_suffix(".rb").split("/", 2)
|
|
29
|
+
rest || folder?(path, first) ? first : ROOT_PACKAGE
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def relative(path) = path.delete_prefix("#{directory_of(path)}/")
|
|
33
|
+
|
|
34
|
+
def label = @directories.join(", ")
|
|
35
|
+
|
|
36
|
+
def root_package = ROOT_PACKAGE
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def folder?(path, name) = Dir.exist?("#{directory_of(path)}/#{name}")
|
|
41
|
+
|
|
42
|
+
def directory_of(path)
|
|
43
|
+
@directories.find { path.start_with?("#{_1}/") } ||
|
|
44
|
+
raise(Error, "#{path} is outside the analyzed directories")
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Report
|
|
5
|
+
class DependencyMap
|
|
6
|
+
def initialize(graph, io: $stdout)
|
|
7
|
+
@graph = graph
|
|
8
|
+
@io = io
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def print
|
|
12
|
+
@io.puts "Dependencies (DependsUpon(refs) -> | <- UsedBy):"
|
|
13
|
+
@graph.packages.sort.each { @io.puts row(_1) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def row(package)
|
|
19
|
+
depends_upon = @graph.dependencies_of(package).map { "#{_1}(#{@graph.weight(package, _1)})" }.join(", ")
|
|
20
|
+
used_by = @graph.dependents_of(package).join(", ")
|
|
21
|
+
format(" %-12s -> %-32s <- %s", package,
|
|
22
|
+
depends_upon.empty? ? "(none)" : depends_upon,
|
|
23
|
+
used_by.empty? ? "(none)" : used_by)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Report
|
|
5
|
+
class FindingLines
|
|
6
|
+
SAMPLE = 4
|
|
7
|
+
|
|
8
|
+
def initialize(finding, indent: "", io: $stdout)
|
|
9
|
+
@finding = finding
|
|
10
|
+
@indent = indent
|
|
11
|
+
@io = io
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def print
|
|
15
|
+
@io.puts "#{@indent}#{@finding.kind}: #{@finding.message}"
|
|
16
|
+
@finding.evidence.first(SAMPLE).each { @io.puts "#{@indent} · #{_1}" }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def print_with_overflow
|
|
20
|
+
print
|
|
21
|
+
overflow = @finding.evidence.size - SAMPLE
|
|
22
|
+
@io.puts "#{@indent} · … (#{overflow} more)" if overflow.positive?
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module Report
|
|
7
|
+
class Json
|
|
8
|
+
def initialize(graph, findings, io: $stdout)
|
|
9
|
+
@graph = graph
|
|
10
|
+
@findings = findings
|
|
11
|
+
@io = io
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def print
|
|
15
|
+
@io.puts JSON.pretty_generate(packages:, edges:, findings: @findings.all.map(&:to_h),
|
|
16
|
+
accepted: accepted_entries)
|
|
17
|
+
0
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def accepted_entries
|
|
23
|
+
@findings.accepted.map { |finding, reason| finding.to_h.merge(reason:) }
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def packages
|
|
27
|
+
@graph.metrics.sort_by { |_package, metric| metric.instability }
|
|
28
|
+
.to_h do |package, metric|
|
|
29
|
+
[package,
|
|
30
|
+
metric.to_h.merge(cyclic: @graph.cyclic?(package))]
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def edges
|
|
35
|
+
@graph.weighted_edges.map do |from, to, weight|
|
|
36
|
+
{ from:, to:, weight:, refs: @graph.evidence_for(from, to).to_a.sort }
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Report
|
|
5
|
+
class MetricsTable
|
|
6
|
+
def initialize(graph, io: $stdout)
|
|
7
|
+
@graph = graph
|
|
8
|
+
@io = io
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def print
|
|
12
|
+
heading
|
|
13
|
+
by_instability.each { |package, metric| @io.puts row(package, metric) }
|
|
14
|
+
legend
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def by_instability = @graph.metrics.sort_by { |_package, metric| metric.instability }
|
|
20
|
+
|
|
21
|
+
def heading
|
|
22
|
+
@io.puts format("%-12s %3s %3s %3s %5s %-3s", *%w[package TC Ca Ce I Cyc])
|
|
23
|
+
@io.puts "-" * 40
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def row(package, metric)
|
|
27
|
+
cyclic = @graph.cyclic?(package) ? "YES" : "-"
|
|
28
|
+
format("%-12s %3d %3d %3d %5.2f %-3s", package,
|
|
29
|
+
metric.type_count, metric.afferent, metric.efferent, metric.instability, cyclic)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def legend
|
|
33
|
+
@io.puts "\nLegend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),"
|
|
34
|
+
@io.puts " I=Ce/(Ce+Ca) instability (0=maximally stable, 1=maximally unstable)\n\n"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Report
|
|
5
|
+
class Text
|
|
6
|
+
def initialize(project, graph, findings, io: $stdout)
|
|
7
|
+
@project = project
|
|
8
|
+
@graph = graph
|
|
9
|
+
@findings = findings
|
|
10
|
+
@io = io
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def print
|
|
14
|
+
header
|
|
15
|
+
MetricsTable.new(@graph, io: @io).print
|
|
16
|
+
DependencyMap.new(@graph, io: @io).print
|
|
17
|
+
findings_section
|
|
18
|
+
0
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def header
|
|
24
|
+
packages = @graph.packages.size
|
|
25
|
+
@io.puts "Package (layer) metrics for #{@project.label} " \
|
|
26
|
+
"(#{packages} packages, #{@project.files.size} files)\n\n"
|
|
27
|
+
single_package_note if packages == 1
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def single_package_note
|
|
31
|
+
@io.puts "Only one package found — there are no boundaries to analyze. " \
|
|
32
|
+
"Pass subdirectories to set them (e.g. hashira lib/gem/*/).\n\n"
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def findings_section
|
|
36
|
+
all = @findings.all
|
|
37
|
+
@io.puts "\nFindings (#{all.size}):"
|
|
38
|
+
print_findings(all)
|
|
39
|
+
accepted_section
|
|
40
|
+
@io.puts "\n Full evidence + machine format: hashira --json" unless all.empty?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def print_findings(all)
|
|
44
|
+
return @io.puts " none ✓ — structure is healthy" if all.empty?
|
|
45
|
+
|
|
46
|
+
all.each { FindingLines.new(_1, indent: " ", io: @io).print_with_overflow }
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def accepted_section
|
|
50
|
+
accepted = @findings.accepted
|
|
51
|
+
return if accepted.empty?
|
|
52
|
+
|
|
53
|
+
@io.puts "\nAccepted (#{accepted.size}):"
|
|
54
|
+
accepted.each { |finding, reason| @io.puts " ~ #{finding.kind}/#{finding.package} — #{reason}" }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|