hashira 0.1.0 → 0.3.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 +97 -0
- data/README.md +334 -32
- data/lib/hashira/analysis/census.rb +12 -14
- data/lib/hashira/analysis/constant_registry.rb +40 -0
- data/lib/hashira/analysis/cycle_findings.rb +1 -1
- data/lib/hashira/analysis/cycle_search.rb +1 -1
- data/lib/hashira/analysis/definitions.rb +1 -1
- data/lib/hashira/analysis/finding.rb +6 -2
- data/lib/hashira/analysis/graph.rb +3 -3
- data/lib/hashira/analysis/namespace_prefix.rb +32 -0
- data/lib/hashira/analysis/node_walk.rb +7 -1
- data/lib/hashira/analysis/references.rb +2 -2
- data/lib/hashira/analysis/sdp_check.rb +1 -1
- data/lib/hashira/analysis/type_walk.rb +1 -1
- data/lib/hashira/churn.rb +22 -0
- data/lib/hashira/ci/accepted.rb +11 -7
- data/lib/hashira/ci/baseline.rb +40 -0
- data/lib/hashira/ci/diff.rb +15 -0
- data/lib/hashira/ci/edge_diff_report.rb +4 -27
- data/lib/hashira/ci/finding_diff_report.rb +26 -0
- data/lib/hashira/ci/gate.rb +7 -5
- data/lib/hashira/ci/improvement.rb +19 -0
- data/lib/hashira/ci/ratchet.rb +16 -20
- data/lib/hashira/ci/ratchet_report.rb +42 -0
- data/lib/hashira/cli/command_line.rb +12 -6
- data/lib/hashira/cli/fail_on.rb +4 -2
- data/lib/hashira/cli/options.rb +1 -1
- data/lib/hashira/cli/run.rb +9 -3
- data/lib/hashira/cli/skip.rb +27 -0
- data/lib/hashira/cli/usage.rb +10 -5
- data/lib/hashira/cli.rb +1 -1
- data/lib/hashira/complexity/analyzer.rb +45 -0
- data/lib/hashira/complexity/boolean_run.rb +22 -0
- data/lib/hashira/complexity/cognitive_score.rb +72 -0
- data/lib/hashira/complexity/if_chain.rb +45 -0
- data/lib/hashira/complexity/method_finding.rb +52 -0
- data/lib/hashira/complexity/method_score.rb +17 -0
- data/lib/hashira/complexity/rescue_scan.rb +28 -0
- data/lib/hashira/complexity/rollup.rb +22 -0
- data/lib/hashira/duplication/analyzer.rb +21 -0
- data/lib/hashira/duplication/cluster.rb +26 -0
- data/lib/hashira/duplication/clusterer.rb +50 -0
- data/lib/hashira/duplication/delta.rb +39 -0
- data/lib/hashira/duplication/duplication_finding.rb +30 -0
- data/lib/hashira/duplication/extractor.rb +31 -0
- data/lib/hashira/duplication/fragment.rb +42 -0
- data/lib/hashira/duplication/grouping.rb +24 -0
- data/lib/hashira/duplication/index.rb +35 -0
- data/lib/hashira/duplication/maximal.rb +21 -0
- data/lib/hashira/duplication/near_miss.rb +33 -0
- data/lib/hashira/duplication/sequence.rb +36 -0
- data/lib/hashira/duplication/similarity.rb +50 -0
- data/lib/hashira/duplication/union_find.rb +21 -0
- data/lib/hashira/duplication/variance.rb +57 -0
- data/lib/hashira/hotspots/file_cost.rb +19 -0
- data/lib/hashira/hotspots/rollup.rb +33 -0
- data/lib/hashira/pipeline.rb +28 -6
- data/lib/hashira/project.rb +32 -9
- data/lib/hashira/report/complexity_table.rb +40 -0
- data/lib/hashira/report/dependency_map.rb +8 -5
- data/lib/hashira/report/finding_lines.rb +1 -1
- data/lib/hashira/report/graph_payload.rb +29 -0
- data/lib/hashira/report/hotspot_table.rb +41 -0
- data/lib/hashira/report/json.rb +24 -17
- data/lib/hashira/report/metrics_table.rb +1 -2
- data/lib/hashira/report/text.rb +24 -14
- data/lib/hashira/report/view.rb +7 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +38 -1
- metadata +46 -7
- data/lib/hashira/analysis/root_namespace.rb +0 -14
|
@@ -2,8 +2,12 @@
|
|
|
2
2
|
|
|
3
3
|
module Hashira
|
|
4
4
|
module Analysis
|
|
5
|
-
Finding = Data.define(:kind, :package, :message, :evidence, :cycle) do
|
|
6
|
-
def initialize(cycle: nil, **rest) = super
|
|
5
|
+
Finding = Data.define(:kind, :package, :message, :evidence, :cycle, :digest) do
|
|
6
|
+
def initialize(cycle: nil, digest: nil, **rest) = super
|
|
7
|
+
|
|
8
|
+
def signature = "#{kind}:#{identity}"
|
|
9
|
+
|
|
10
|
+
def identity = digest || package
|
|
7
11
|
|
|
8
12
|
def to_h = super.compact
|
|
9
13
|
end
|
|
@@ -13,10 +13,10 @@ module Hashira
|
|
|
13
13
|
|
|
14
14
|
def dependencies_of(package) = dependencies[package].to_a.sort
|
|
15
15
|
|
|
16
|
-
def dependents_of(package) = packages.select { dependencies[
|
|
16
|
+
def dependents_of(package) = packages.select { dependencies[it].include?(package) }.sort
|
|
17
17
|
|
|
18
18
|
def edge_list
|
|
19
|
-
dependencies.sort.flat_map { |from, tos| tos.sort.map { Edge.new(from:, to:
|
|
19
|
+
dependencies.sort.flat_map { |from, tos| tos.sort.map { Edge.new(from:, to: it) } }
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def weighted_edges
|
|
@@ -34,7 +34,7 @@ module Hashira
|
|
|
34
34
|
efferent: dependencies[package].size)
|
|
35
35
|
end
|
|
36
36
|
|
|
37
|
-
def metrics = packages.to_h { [
|
|
37
|
+
def metrics = packages.to_h { [it, metric_for(it)] }
|
|
38
38
|
|
|
39
39
|
def sdp_violations = SdpCheck.new(dependencies, metrics).violations
|
|
40
40
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Analysis
|
|
5
|
+
module NamespacePrefix
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def infer(definitions)
|
|
9
|
+
build(deepest_definition_per_package(definitions))
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def build(fulls, prefix = [])
|
|
13
|
+
depth = prefix.length
|
|
14
|
+
wrapper = shared_wrapper(fulls, depth)
|
|
15
|
+
return prefix unless wrapper
|
|
16
|
+
|
|
17
|
+
build(fulls.select { it[depth] == wrapper }, prefix + [wrapper])
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def deepest_definition_per_package(definitions)
|
|
21
|
+
definitions.map { |_node, full, package| [package, full] }
|
|
22
|
+
.sort_by { |_package, full| full.length }
|
|
23
|
+
.to_h.values
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def shared_wrapper(fulls, depth)
|
|
27
|
+
wrapper, count = fulls.filter_map { it[depth] if it[depth + 1] }.tally.max_by { |_wrapper, tally| tally }
|
|
28
|
+
wrapper if wrapper && count > fulls.size / 2
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -7,7 +7,13 @@ module Hashira
|
|
|
7
7
|
|
|
8
8
|
def each_node(node, &)
|
|
9
9
|
yield(node)
|
|
10
|
-
node.compact_child_nodes.each { each_node(
|
|
10
|
+
node.compact_child_nodes.each { each_node(it, &) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def collect(node)
|
|
14
|
+
found = []
|
|
15
|
+
each_node(node) { found << it }
|
|
16
|
+
found
|
|
11
17
|
end
|
|
12
18
|
end
|
|
13
19
|
end
|
|
@@ -12,14 +12,14 @@ module Hashira
|
|
|
12
12
|
end
|
|
13
13
|
|
|
14
14
|
def each_sighting(tree)
|
|
15
|
-
[].tap { collect(tree,
|
|
15
|
+
[].tap { collect(tree, it) }
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def collect(node, accumulator)
|
|
19
19
|
return unless node
|
|
20
20
|
return accumulator << [Syntax.path_segments(node), node.location.start_line] if constant?(node)
|
|
21
21
|
|
|
22
|
-
branches(node).each { collect(
|
|
22
|
+
branches(node).each { collect(it, accumulator) }
|
|
23
23
|
end
|
|
24
24
|
|
|
25
25
|
def constant?(node) = node.is_a?(Prism::ConstantPathNode) || node.is_a?(Prism::ConstantReadNode)
|
|
@@ -10,7 +10,7 @@ module Hashira
|
|
|
10
10
|
|
|
11
11
|
def violations
|
|
12
12
|
@dependencies.flat_map do |from, tos|
|
|
13
|
-
tos.select { @metrics[
|
|
13
|
+
tos.select { @metrics[it].instability > @metrics[from].instability }.map { [from, it] }
|
|
14
14
|
end
|
|
15
15
|
end
|
|
16
16
|
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
class Churn
|
|
5
|
+
LOG = "git log --name-only --format= 2>/dev/null"
|
|
6
|
+
SITES_THAT_DRIFT_APART = 2
|
|
7
|
+
|
|
8
|
+
def self.from_git = new(tally(`#{LOG}`))
|
|
9
|
+
|
|
10
|
+
def self.tally(output) = output.split("\n").map(&:strip).reject(&:empty?).tally
|
|
11
|
+
|
|
12
|
+
def initialize(counts)
|
|
13
|
+
@counts = counts
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def hits(file) = @counts.select { |path, _| path.end_with?(file) }.values.max || 0
|
|
17
|
+
|
|
18
|
+
def hot?(members) = changing(members) >= SITES_THAT_DRIFT_APART
|
|
19
|
+
|
|
20
|
+
def changing(members) = members.count { |member| hits(member.file).positive? }
|
|
21
|
+
end
|
|
22
|
+
end
|
data/lib/hashira/ci/accepted.rb
CHANGED
|
@@ -7,14 +7,18 @@ module Hashira
|
|
|
7
7
|
class Accepted
|
|
8
8
|
Screened = Data.define(:all, :accepted)
|
|
9
9
|
|
|
10
|
-
Entry = Data.define(:kind, :package, :reason) do
|
|
11
|
-
def self.from(hash)
|
|
10
|
+
Entry = Data.define(:kind, :package, :digest, :reason) do
|
|
11
|
+
def self.from(hash)
|
|
12
|
+
new(kind: hash["kind"], package: hash["package"], digest: hash["digest"], reason: hash["reason"])
|
|
13
|
+
end
|
|
12
14
|
|
|
13
|
-
def matches?(finding) =
|
|
15
|
+
def matches?(finding) = kind == finding.kind && identity == finding.identity
|
|
16
|
+
|
|
17
|
+
def identity = digest || package
|
|
14
18
|
|
|
15
19
|
def label = reason || "accepted (no reason recorded)"
|
|
16
20
|
|
|
17
|
-
def to_h = { kind:, package:, reason: }.compact
|
|
21
|
+
def to_h = { kind:, package:, digest:, reason: }.compact
|
|
18
22
|
end
|
|
19
23
|
|
|
20
24
|
def self.load(path)
|
|
@@ -24,20 +28,20 @@ module Hashira
|
|
|
24
28
|
end
|
|
25
29
|
|
|
26
30
|
def initialize(entries)
|
|
27
|
-
@entries = entries.map { Entry.from(
|
|
31
|
+
@entries = entries.map { Entry.from(it) }
|
|
28
32
|
end
|
|
29
33
|
|
|
30
34
|
def entries = @entries.map(&:to_h)
|
|
31
35
|
|
|
32
36
|
def screen(findings)
|
|
33
|
-
accepted, live = findings.map { [
|
|
37
|
+
accepted, live = findings.map { [it, reason_for(it)] }.partition(&:last)
|
|
34
38
|
Screened.new(all: live.map(&:first), accepted:)
|
|
35
39
|
end
|
|
36
40
|
|
|
37
41
|
private
|
|
38
42
|
|
|
39
43
|
def reason_for(finding)
|
|
40
|
-
@entries.find {
|
|
44
|
+
@entries.find { it.matches?(finding) }&.label
|
|
41
45
|
end
|
|
42
46
|
end
|
|
43
47
|
end
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module CI
|
|
7
|
+
class Baseline
|
|
8
|
+
SCHEMA_VERSION = 2
|
|
9
|
+
|
|
10
|
+
def self.load(path) = new(path, File.exist?(path) ? JSON.parse(File.read(path)) : {})
|
|
11
|
+
|
|
12
|
+
def initialize(path, recorded)
|
|
13
|
+
@path = path
|
|
14
|
+
@recorded = recorded
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
attr_reader :path
|
|
18
|
+
|
|
19
|
+
def exist? = File.exist?(@path)
|
|
20
|
+
|
|
21
|
+
def edges = @recorded.fetch("edges", [])
|
|
22
|
+
|
|
23
|
+
def findings = @recorded.fetch("findings", [])
|
|
24
|
+
|
|
25
|
+
def ratchets_findings? = @recorded.key?("findings")
|
|
26
|
+
|
|
27
|
+
def write(edges, findings)
|
|
28
|
+
File.write(@path, JSON.pretty_generate(payload(edges, findings)) << "\n")
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
private
|
|
32
|
+
|
|
33
|
+
def payload(edges, findings)
|
|
34
|
+
base = { version: SCHEMA_VERSION, edges:, findings: }
|
|
35
|
+
accepted = Accepted.new(@recorded.fetch("accepted", [])).entries
|
|
36
|
+
accepted.empty? ? base : base.merge(accepted:)
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module CI
|
|
5
|
+
Diff = Data.define(:added, :removed) do
|
|
6
|
+
def self.between(current, recorded)
|
|
7
|
+
new(added: current - recorded, removed: recorded - current)
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def empty? = added.empty? && removed.empty?
|
|
11
|
+
|
|
12
|
+
def worse? = !added.empty?
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -8,39 +8,16 @@ module Hashira
|
|
|
8
8
|
@io = io
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
-
def print(
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
print_added(added)
|
|
15
|
-
print_removed(removed)
|
|
16
|
-
1
|
|
11
|
+
def print(diff)
|
|
12
|
+
diff.added.each { print_edge(it) }
|
|
13
|
+
Improvement.new("Edges removed", io: @io).print(diff.removed)
|
|
17
14
|
end
|
|
18
15
|
|
|
19
16
|
private
|
|
20
17
|
|
|
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
18
|
def print_edge(edge)
|
|
35
19
|
@io.puts "NEW EDGE #{edge} — introduced by:"
|
|
36
|
-
@graph.evidence_for(edge.from, edge.to).to_a.sort.each { @io.puts " · #{
|
|
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"
|
|
20
|
+
@graph.evidence_for(edge.from, edge.to).to_a.sort.each { @io.puts " · #{it}" }
|
|
44
21
|
end
|
|
45
22
|
end
|
|
46
23
|
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module CI
|
|
5
|
+
class FindingDiffReport
|
|
6
|
+
def initialize(findings, io: $stdout)
|
|
7
|
+
@findings = findings
|
|
8
|
+
@io = io
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def print(diff)
|
|
12
|
+
introduced(diff.added).each { print_finding(it) }
|
|
13
|
+
Improvement.new("Findings resolved", io: @io).print(diff.removed)
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def introduced(added) = added.filter_map { |signature| @findings.find { it.signature == signature } }
|
|
19
|
+
|
|
20
|
+
def print_finding(finding)
|
|
21
|
+
@io.puts "NEW FINDING:"
|
|
22
|
+
Report::FindingLines.new(finding, indent: " ", io: @io).print_with_overflow
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
data/lib/hashira/ci/gate.rb
CHANGED
|
@@ -10,16 +10,18 @@ module Hashira
|
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def check
|
|
13
|
-
offending = @findings.all.select { @kinds.include?(
|
|
14
|
-
|
|
13
|
+
offending = @findings.all.select { @kinds.include?(it.kind) }
|
|
14
|
+
offending.empty? ? report_clean : report_failure(offending)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
15
18
|
|
|
16
|
-
|
|
19
|
+
def report_failure(offending)
|
|
20
|
+
offending.each { Report::FindingLines.new(it, io: @io).print }
|
|
17
21
|
@io.puts "\nGate FAILED: #{offending.size} finding(s) of kind #{@kinds.join(", ")}."
|
|
18
22
|
1
|
|
19
23
|
end
|
|
20
24
|
|
|
21
|
-
private
|
|
22
|
-
|
|
23
25
|
def report_clean
|
|
24
26
|
@io.puts "Gate OK: no findings of kind #{@kinds.join(", ")}."
|
|
25
27
|
0
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module CI
|
|
5
|
+
class Improvement
|
|
6
|
+
def initialize(label, io: $stdout)
|
|
7
|
+
@label = label
|
|
8
|
+
@io = io
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def print(removed)
|
|
12
|
+
return if removed.empty?
|
|
13
|
+
|
|
14
|
+
@io.puts "#{@label} (improvement!): #{removed.join(", ")}"
|
|
15
|
+
@io.puts "Lock it in: hashira --update-baseline"
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
19
|
+
end
|
data/lib/hashira/ci/ratchet.rb
CHANGED
|
@@ -1,43 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
require "json"
|
|
4
|
-
|
|
5
3
|
module Hashira
|
|
6
4
|
module CI
|
|
7
5
|
class Ratchet
|
|
8
|
-
def initialize(graph, baseline_path, io: $stdout)
|
|
6
|
+
def initialize(graph, findings, baseline_path, io: $stdout)
|
|
9
7
|
@graph = graph
|
|
10
|
-
@
|
|
8
|
+
@findings = findings
|
|
9
|
+
@baseline = Baseline.load(baseline_path)
|
|
11
10
|
@io = io
|
|
12
11
|
end
|
|
13
12
|
|
|
14
|
-
SCHEMA_VERSION = 1
|
|
15
|
-
|
|
16
13
|
def update
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
@io.puts "Baseline updated: #{edges.size} edges."
|
|
14
|
+
@baseline.write(edge_signatures, finding_signatures)
|
|
15
|
+
@io.puts "Baseline updated: #{edge_signatures.size} edges, #{finding_signatures.size} findings."
|
|
20
16
|
0
|
|
21
17
|
end
|
|
22
18
|
|
|
23
19
|
def check
|
|
24
|
-
|
|
25
|
-
|
|
20
|
+
raise Error, "no baseline at #{@baseline.path} — run --update-baseline first" unless @baseline.exist?
|
|
21
|
+
|
|
22
|
+
RatchetReport.new(@graph, @findings, io: @io).print(edge_diff, finding_diff)
|
|
26
23
|
end
|
|
27
24
|
|
|
28
25
|
private
|
|
29
26
|
|
|
30
|
-
def
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
27
|
+
def edge_signatures = @graph.edge_list.map(&:to_s)
|
|
28
|
+
|
|
29
|
+
def finding_signatures = @findings.map(&:signature).uniq.sort
|
|
30
|
+
|
|
31
|
+
def edge_diff = Diff.new(added: new_edges, removed: @baseline.edges - edge_signatures)
|
|
35
32
|
|
|
36
|
-
def
|
|
37
|
-
raise Error, "no baseline at #{@baseline_path} — run --update-baseline first" unless File.exist?(@baseline_path)
|
|
33
|
+
def new_edges = @graph.edge_list.reject { @baseline.edges.include?(it.to_s) }
|
|
38
34
|
|
|
39
|
-
|
|
40
|
-
|
|
35
|
+
def finding_diff
|
|
36
|
+
Diff.between(finding_signatures, @baseline.findings) if @baseline.ratchets_findings?
|
|
41
37
|
end
|
|
42
38
|
end
|
|
43
39
|
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module CI
|
|
5
|
+
class RatchetReport
|
|
6
|
+
def initialize(graph, findings, io: $stdout)
|
|
7
|
+
@graph = graph
|
|
8
|
+
@findings = findings
|
|
9
|
+
@io = io
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def print(edges, findings)
|
|
13
|
+
return unchanged if quiet?(edges, findings)
|
|
14
|
+
|
|
15
|
+
details(edges, findings)
|
|
16
|
+
advice(edges, findings)
|
|
17
|
+
1
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
private
|
|
21
|
+
|
|
22
|
+
def quiet?(*diffs) = diffs.compact.all?(&:empty?)
|
|
23
|
+
|
|
24
|
+
def unchanged
|
|
25
|
+
@io.puts "Ratchet OK: #{@graph.edge_list.size} edges, #{@findings.size} findings, unchanged."
|
|
26
|
+
0
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def details(edges, findings)
|
|
30
|
+
EdgeDiffReport.new(@graph, io: @io).print(edges)
|
|
31
|
+
FindingDiffReport.new(@findings, io: @io).print(findings) if findings
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def advice(*diffs)
|
|
35
|
+
return unless diffs.compact.any?(&:worse?)
|
|
36
|
+
|
|
37
|
+
@io.puts "\nRatchet FAILED. Either fix what regressed, or — if it is deliberate —"
|
|
38
|
+
@io.puts "record the decision: update the baseline, or accept it with a reason."
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -23,7 +23,7 @@ module Hashira
|
|
|
23
23
|
usage(:version) if delete("--version")
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
-
def usage(mode) = Options.new(directories: [], mode:, baseline: nil, fail_on: [])
|
|
26
|
+
def usage(mode) = Options.new(directories: [], mode:, baseline: nil, fail_on: [], skip: [])
|
|
27
27
|
|
|
28
28
|
def parsed_options
|
|
29
29
|
values = flag_values
|
|
@@ -33,15 +33,21 @@ module Hashira
|
|
|
33
33
|
end
|
|
34
34
|
|
|
35
35
|
def flag_values
|
|
36
|
-
{
|
|
36
|
+
{ skip: Skip.parse(take_value("--skip")),
|
|
37
|
+
fail_on: FailOn.parse(take_value("--fail-on")),
|
|
37
38
|
baseline: take_value("--baseline") || DEFAULT_BASELINE }
|
|
38
39
|
end
|
|
39
40
|
|
|
40
41
|
def parse_mode(fail_on)
|
|
41
|
-
requested = requested_modes(fail_on).uniq(&:last)
|
|
42
|
-
|
|
42
|
+
case (requested = requested_modes(fail_on).uniq(&:last))
|
|
43
|
+
in [] then :text
|
|
44
|
+
in [[_flag, mode]] then mode
|
|
45
|
+
else conflict(requested)
|
|
46
|
+
end
|
|
47
|
+
end
|
|
43
48
|
|
|
44
|
-
|
|
49
|
+
def conflict(requested)
|
|
50
|
+
raise Error, "conflicting options: #{requested.map(&:first).join(" and ")}"
|
|
45
51
|
end
|
|
46
52
|
|
|
47
53
|
def requested_modes(fail_on)
|
|
@@ -78,7 +84,7 @@ module Hashira
|
|
|
78
84
|
def delete(flag) = @arguments.delete(flag)
|
|
79
85
|
|
|
80
86
|
def reject_unknown_flags
|
|
81
|
-
stray = @arguments.find {
|
|
87
|
+
stray = @arguments.find { it.start_with?("-") }
|
|
82
88
|
raise Error, "unknown option #{stray}" if stray
|
|
83
89
|
end
|
|
84
90
|
end
|
data/lib/hashira/cli/fail_on.rb
CHANGED
|
@@ -5,7 +5,9 @@ module Hashira
|
|
|
5
5
|
module FailOn
|
|
6
6
|
KINDS = {
|
|
7
7
|
"cycles" => "cycle", "cycle" => "cycle",
|
|
8
|
-
"sdp" => "sdp_violation", "sdp_violation" => "sdp_violation"
|
|
8
|
+
"sdp" => "sdp_violation", "sdp_violation" => "sdp_violation",
|
|
9
|
+
"complexity" => "complexity",
|
|
10
|
+
"duplication" => "duplication", "dupe" => "duplication"
|
|
9
11
|
}.freeze
|
|
10
12
|
|
|
11
13
|
module_function
|
|
@@ -13,7 +15,7 @@ module Hashira
|
|
|
13
15
|
def parse(list)
|
|
14
16
|
return [] unless list
|
|
15
17
|
|
|
16
|
-
list.split(",").map { kind(
|
|
18
|
+
list.split(",").map { kind(it.strip) }.uniq
|
|
17
19
|
end
|
|
18
20
|
|
|
19
21
|
def kind(name)
|
data/lib/hashira/cli/options.rb
CHANGED
data/lib/hashira/cli/run.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Hashira
|
|
|
18
18
|
|
|
19
19
|
def graph = @pipeline.graph
|
|
20
20
|
|
|
21
|
-
def ratchet = CI::Ratchet.new(graph, @options.baseline)
|
|
21
|
+
def ratchet = CI::Ratchet.new(graph, findings.all, @options.baseline)
|
|
22
22
|
|
|
23
23
|
def update_baseline = ratchet.update
|
|
24
24
|
|
|
@@ -28,11 +28,17 @@ module Hashira
|
|
|
28
28
|
|
|
29
29
|
def check_gate = CI::Gate.new(findings, @options.fail_on).check
|
|
30
30
|
|
|
31
|
-
def print_json = Report::Json.new(
|
|
31
|
+
def print_json = Report::Json.new(view).print
|
|
32
32
|
|
|
33
33
|
def print_diagram = Diagram::Renderer.new(graph, @options.mode).display
|
|
34
34
|
|
|
35
|
-
def print_text = Report::Text.new(
|
|
35
|
+
def print_text = Report::Text.new(view).print
|
|
36
|
+
|
|
37
|
+
def view
|
|
38
|
+
Report::View.new(project: @pipeline.project, graph: (graph if @pipeline.enabled?(:coupling)),
|
|
39
|
+
complexity: @pipeline.complexity, duplication: @pipeline.duplication,
|
|
40
|
+
hotspots: @pipeline.hotspots, findings:)
|
|
41
|
+
end
|
|
36
42
|
end
|
|
37
43
|
end
|
|
38
44
|
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
class CLI
|
|
5
|
+
module Skip
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def parse(list)
|
|
9
|
+
return [] unless list
|
|
10
|
+
|
|
11
|
+
keys = list.split(",").map { key(it.strip) }
|
|
12
|
+
raise Error, "cannot skip every analyzer" if (Pipeline::ANALYZERS - keys).empty?
|
|
13
|
+
|
|
14
|
+
keys
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def key(name)
|
|
18
|
+
symbol = name.to_sym
|
|
19
|
+
Pipeline::ANALYZERS.include?(symbol) ? symbol : unknown(name)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def unknown(name)
|
|
23
|
+
raise Error, "unknown --skip #{name.inspect} (use: #{Pipeline::ANALYZERS.join(", ")})"
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
data/lib/hashira/cli/usage.rb
CHANGED
|
@@ -6,16 +6,19 @@ module Hashira
|
|
|
6
6
|
TEXT = <<~HELP
|
|
7
7
|
Usage: hashira [DIRECTORY ...] [options]
|
|
8
8
|
|
|
9
|
-
|
|
10
|
-
|
|
9
|
+
Coupling, cognitive-complexity, and duplication metrics for Ruby, via
|
|
10
|
+
Prism, rolled up per file as a ranked list of hotspots. With no
|
|
11
|
+
directory, auto-detects lib/<gem>.
|
|
11
12
|
|
|
12
13
|
Options:
|
|
13
14
|
--format FORMAT text (default), json, dot, or mermaid
|
|
14
15
|
--json shorthand for --format json
|
|
15
16
|
--fail-on KINDS exit 1 if findings exist; comma-separated
|
|
16
|
-
kinds: cycles, sdp
|
|
17
|
-
--
|
|
18
|
-
--
|
|
17
|
+
kinds: cycles, sdp, complexity, duplication
|
|
18
|
+
--skip ANALYZERS drop an analyzer; comma-separated: coupling, complexity, duplication
|
|
19
|
+
--ratchet fail when edges or findings appear that the
|
|
20
|
+
baseline lacks
|
|
21
|
+
--update-baseline record the current edges and findings
|
|
19
22
|
--baseline PATH baseline file (default: hashira_baseline.json)
|
|
20
23
|
-h, --help print this help
|
|
21
24
|
--version print the version
|
|
@@ -23,6 +26,8 @@ module Hashira
|
|
|
23
26
|
Findings accepted by design can be recorded in the baseline as
|
|
24
27
|
"accepted": [{"kind": "...", "package": "...", "reason": "..."}]
|
|
25
28
|
— they leave reports and gates, keeping a one-line reminder each.
|
|
29
|
+
Clones are named by "digest" instead of "package", so an acceptance
|
|
30
|
+
survives the lines above it moving. Read digests from --json.
|
|
26
31
|
HELP
|
|
27
32
|
|
|
28
33
|
module_function
|
data/lib/hashira/cli.rb
CHANGED
|
@@ -18,7 +18,7 @@ module Hashira
|
|
|
18
18
|
|
|
19
19
|
def initialize(options)
|
|
20
20
|
@options = options
|
|
21
|
-
@pipeline = Pipeline.new(Project.detect(options.directories))
|
|
21
|
+
@pipeline = Pipeline.new(Project.detect(options.directories), enabled: Pipeline::ANALYZERS - options.skip)
|
|
22
22
|
end
|
|
23
23
|
|
|
24
24
|
def run = Run.new(@pipeline, @options).exit_code
|