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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +98 -0
  5. data/exe/hashira +9 -0
  6. data/lib/hashira/analysis/census.rb +41 -0
  7. data/lib/hashira/analysis/cycle_findings.rb +33 -0
  8. data/lib/hashira/analysis/cycle_search.rb +44 -0
  9. data/lib/hashira/analysis/definitions.rb +27 -0
  10. data/lib/hashira/analysis/edge.rb +9 -0
  11. data/lib/hashira/analysis/edge_map.rb +36 -0
  12. data/lib/hashira/analysis/finding.rb +11 -0
  13. data/lib/hashira/analysis/graph.rb +56 -0
  14. data/lib/hashira/analysis/metric.rb +14 -0
  15. data/lib/hashira/analysis/node_walk.rb +14 -0
  16. data/lib/hashira/analysis/references.rb +39 -0
  17. data/lib/hashira/analysis/root_namespace.rb +14 -0
  18. data/lib/hashira/analysis/rule.rb +22 -0
  19. data/lib/hashira/analysis/sdp_check.rb +18 -0
  20. data/lib/hashira/analysis/sdp_violation_findings.rb +27 -0
  21. data/lib/hashira/analysis/syntax.rb +27 -0
  22. data/lib/hashira/analysis/type_walk.rb +26 -0
  23. data/lib/hashira/ci/accepted.rb +44 -0
  24. data/lib/hashira/ci/edge_diff_report.rb +47 -0
  25. data/lib/hashira/ci/gate.rb +29 -0
  26. data/lib/hashira/ci/ratchet.rb +44 -0
  27. data/lib/hashira/cli/command_line.rb +86 -0
  28. data/lib/hashira/cli/fail_on.rb +26 -0
  29. data/lib/hashira/cli/options.rb +9 -0
  30. data/lib/hashira/cli/run.rb +38 -0
  31. data/lib/hashira/cli/usage.rb +40 -0
  32. data/lib/hashira/cli.rb +26 -0
  33. data/lib/hashira/diagram/dot.rb +16 -0
  34. data/lib/hashira/diagram/mermaid.rb +23 -0
  35. data/lib/hashira/diagram/renderer.rb +20 -0
  36. data/lib/hashira/error.rb +5 -0
  37. data/lib/hashira/pipeline.rb +28 -0
  38. data/lib/hashira/project.rb +47 -0
  39. data/lib/hashira/report/dependency_map.rb +27 -0
  40. data/lib/hashira/report/finding_lines.rb +26 -0
  41. data/lib/hashira/report/json.rb +41 -0
  42. data/lib/hashira/report/metrics_table.rb +38 -0
  43. data/lib/hashira/report/text.rb +58 -0
  44. data/lib/hashira/version.rb +5 -0
  45. data/lib/hashira.rb +41 -0
  46. 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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ class CLI
5
+ Options = Data.define(:directories, :mode, :baseline, :fail_on) do
6
+ def self.parse(argv) = CommandLine.new(argv).options
7
+ end
8
+ end
9
+ 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
@@ -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,5 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ class Error < StandardError; end
5
+ 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