hashira 0.1.0 → 0.2.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 +53 -0
- data/README.md +327 -32
- data/lib/hashira/analysis/census.rb +0 -2
- 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/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 +24 -0
- data/lib/hashira/duplication/clusterer.rb +44 -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 +34 -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 +10 -7
- 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 +36 -0
- metadata +44 -6
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
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Complexity
|
|
5
|
+
class Analyzer
|
|
6
|
+
THRESHOLD = 10
|
|
7
|
+
|
|
8
|
+
def initialize(project, trees)
|
|
9
|
+
@project = project
|
|
10
|
+
@scores = trees.flat_map { |path, tree| scores_for(path, tree) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def methods = @scores.sort_by { -it.cognitive }
|
|
14
|
+
|
|
15
|
+
def classes = Rollup.new(@scores).classes.sort_by { -it.cognitive }
|
|
16
|
+
|
|
17
|
+
def findings = flagged.map { MethodFinding.new(it).to_finding }
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def flagged = methods.select { it.cognitive >= THRESHOLD }
|
|
22
|
+
|
|
23
|
+
def scores_for(path, tree)
|
|
24
|
+
rel = @project.relative(path)
|
|
25
|
+
methods_in(tree).map { |full, node| build_score(rel, full, node) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def methods_in(tree)
|
|
29
|
+
found = []
|
|
30
|
+
Analysis::TypeWalk.each_definition(tree) do |type_node, full|
|
|
31
|
+
Analysis::Syntax.direct_definitions(type_node).each { found << [full, it] }
|
|
32
|
+
end
|
|
33
|
+
found
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_score(rel, full, node)
|
|
37
|
+
score = CognitiveScore.new(node)
|
|
38
|
+
MethodScore.new(subject: subject(full, node), file: rel, line: node.location.start_line,
|
|
39
|
+
cognitive: score.total, calls: score.calls, increments: score.increments)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def subject(full, node) = "#{full.join("::")}#{node.receiver ? "." : "#"}#{node.name}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Complexity
|
|
5
|
+
class BooleanRun
|
|
6
|
+
def initialize(scorer)
|
|
7
|
+
@scorer = scorer
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def apply(node, nesting)
|
|
11
|
+
@scorer.add(node, 1, "boolean")
|
|
12
|
+
operands(node).each { @scorer.visit(it, nesting) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def operands(node)
|
|
18
|
+
node.compact_child_nodes.flat_map { it.instance_of?(node.class) ? operands(it) : [it] }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module Complexity
|
|
7
|
+
class CognitiveScore
|
|
8
|
+
HANDLERS = {
|
|
9
|
+
Prism::IfNode => :on_if,
|
|
10
|
+
Prism::UnlessNode => :on_nester,
|
|
11
|
+
Prism::WhileNode => :on_nester,
|
|
12
|
+
Prism::UntilNode => :on_nester,
|
|
13
|
+
Prism::ForNode => :on_nester,
|
|
14
|
+
Prism::CaseNode => :on_nester,
|
|
15
|
+
Prism::CaseMatchNode => :on_nester,
|
|
16
|
+
Prism::BeginNode => :on_begin,
|
|
17
|
+
Prism::AndNode => :on_boolean,
|
|
18
|
+
Prism::OrNode => :on_boolean,
|
|
19
|
+
Prism::BlockNode => :on_block,
|
|
20
|
+
Prism::CallNode => :on_call
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
LABELS = {
|
|
24
|
+
Prism::UnlessNode => "unless", Prism::WhileNode => "while",
|
|
25
|
+
Prism::UntilNode => "until", Prism::ForNode => "for",
|
|
26
|
+
Prism::CaseNode => "case", Prism::CaseMatchNode => "case"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(def_node)
|
|
30
|
+
@increments = []
|
|
31
|
+
@calls = 0
|
|
32
|
+
visit(def_node.body, 0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :increments, :calls
|
|
36
|
+
|
|
37
|
+
def total = @increments.sum(&:cost)
|
|
38
|
+
|
|
39
|
+
def visit(node, nesting)
|
|
40
|
+
return unless node
|
|
41
|
+
|
|
42
|
+
send(HANDLERS.fetch(node.class, :descend), node, nesting)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add(node, cost, label)
|
|
46
|
+
@increments << Increment.new(line: node.location.start_line, cost:, label:)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def descend(node, nesting) = node.compact_child_nodes.each { visit(it, nesting) }
|
|
52
|
+
|
|
53
|
+
def on_call(node, nesting)
|
|
54
|
+
@calls += 1
|
|
55
|
+
descend(node, nesting)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def on_block(node, nesting) = node.compact_child_nodes.each { visit(it, nesting + 1) }
|
|
59
|
+
|
|
60
|
+
def on_nester(node, nesting)
|
|
61
|
+
add(node, 1 + nesting, LABELS.fetch(node.class))
|
|
62
|
+
node.compact_child_nodes.each { visit(it, nesting + 1) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def on_if(node, nesting) = IfChain.new(self).apply(node, nesting)
|
|
66
|
+
|
|
67
|
+
def on_begin(node, nesting) = RescueScan.new(self).apply(node, nesting)
|
|
68
|
+
|
|
69
|
+
def on_boolean(node, nesting) = BooleanRun.new(self).apply(node, nesting)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|