hashira 0.6.0 → 0.8.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 +106 -0
- data/README.md +148 -53
- data/exe/hashira +1 -1
- data/lib/hashira/analysis/finding.rb +5 -1
- data/lib/hashira/analysis/syntax.rb +6 -6
- data/lib/hashira/churn.rb +3 -4
- data/lib/hashira/ci/accepted.rb +9 -10
- data/lib/hashira/ci/baseline.rb +39 -12
- data/lib/hashira/ci/comparison.rb +24 -0
- data/lib/hashira/ci/diff.rb +4 -8
- data/lib/hashira/ci/finding_diff_report.rb +10 -0
- data/lib/hashira/ci/gate.rb +10 -2
- data/lib/hashira/ci/improvement.rb +1 -1
- data/lib/hashira/ci/ratchet.rb +41 -10
- data/lib/hashira/ci/ratchet_report.rb +8 -6
- data/lib/hashira/ci/scope.rb +10 -0
- data/lib/hashira/ci/slice.rb +17 -0
- data/lib/hashira/ci/status.rb +7 -0
- data/lib/hashira/ci/sweep.rb +13 -0
- data/lib/hashira/cli/arguments.rb +17 -8
- data/lib/hashira/cli/choice.rb +9 -0
- data/lib/hashira/cli/command_line.rb +8 -5
- data/lib/hashira/cli/fail_on.rb +17 -7
- data/lib/hashira/cli/flag.rb +3 -1
- data/lib/hashira/cli/flags.rb +23 -0
- data/lib/hashira/cli/format.rb +1 -1
- data/lib/hashira/cli/needs.rb +45 -0
- data/lib/hashira/cli/only.rb +16 -0
- data/lib/hashira/cli/options.rb +12 -3
- data/lib/hashira/cli/package_by.rb +1 -1
- data/lib/hashira/cli/run.rb +36 -8
- data/lib/hashira/cli/top.rb +13 -0
- data/lib/hashira/cli/usage.rb +5 -1
- data/lib/hashira/cli.rb +31 -11
- data/lib/hashira/complexity/cognitive_score.rb +44 -33
- data/lib/hashira/complexity/method_finding.rb +7 -5
- data/lib/hashira/complexity/method_score.rb +1 -1
- data/lib/hashira/complexity/scores.rb +11 -9
- data/lib/hashira/coupling/audiences.rb +3 -3
- data/lib/hashira/coupling/catalog.rb +8 -6
- data/lib/hashira/coupling/census.rb +42 -22
- data/lib/hashira/coupling/cycle_search.rb +11 -7
- data/lib/hashira/coupling/definitions.rb +1 -1
- data/lib/hashira/coupling/edge_map.rb +9 -8
- data/lib/hashira/coupling/folding.rb +5 -5
- data/lib/hashira/coupling/graph.rb +16 -9
- data/lib/hashira/coupling/metric.rb +9 -1
- data/lib/hashira/coupling/naming.rb +4 -4
- data/lib/hashira/coupling/references.rb +12 -12
- data/lib/hashira/coupling/report.rb +7 -9
- data/lib/hashira/coupling/roll_call.rb +1 -1
- data/lib/hashira/coupling/roll_call_findings.rb +1 -1
- data/lib/hashira/coupling/roster.rb +37 -12
- data/lib/hashira/coupling/rule.rb +1 -1
- data/lib/hashira/coupling/sdp_violation_findings.rb +3 -1
- data/lib/hashira/diagram/dot.rb +9 -6
- data/lib/hashira/diagram/mermaid.rb +11 -8
- data/lib/hashira/diagram/source.rb +5 -2
- data/lib/hashira/duplication/clones.rb +1 -1
- data/lib/hashira/duplication/clusters.rb +10 -7
- data/lib/hashira/duplication/delta.rb +1 -4
- data/lib/hashira/duplication/duplication_finding.rb +4 -2
- data/lib/hashira/duplication/fragment.rb +2 -2
- data/lib/hashira/duplication/harvest.rb +7 -5
- data/lib/hashira/duplication/index.rb +3 -2
- data/lib/hashira/duplication/similarity.rb +2 -2
- data/lib/hashira/duplication/variance.rb +1 -1
- data/lib/hashira/error.rb +0 -3
- data/lib/hashira/focus.rb +29 -0
- data/lib/hashira/git_log.rb +19 -0
- data/lib/hashira/hotspots/file_cost.rb +1 -1
- data/lib/hashira/hotspots/rollup.rb +2 -2
- data/lib/hashira/pipeline.rb +26 -24
- data/lib/hashira/project.rb +47 -32
- data/lib/hashira/report/columns.rb +54 -0
- data/lib/hashira/report/complexity_table.rb +12 -16
- data/lib/hashira/report/dependency_map.rb +1 -0
- data/lib/hashira/report/graph_payload.rb +1 -1
- data/lib/hashira/report/hotspot_table.rb +6 -14
- data/lib/hashira/report/json.rb +10 -2
- data/lib/hashira/report/metrics_table.rb +19 -20
- data/lib/hashira/report/notices.rb +37 -0
- data/lib/hashira/report/phrases.rb +25 -18
- data/lib/hashira/report/text.rb +24 -9
- data/lib/hashira/report/view.rb +4 -1
- data/lib/hashira/smells/census.rb +3 -4
- data/lib/hashira/smells/check.rb +1 -5
- data/lib/hashira/smells/contexts.rb +4 -4
- data/lib/hashira/smells/control_parameter.rb +1 -1
- data/lib/hashira/smells/data_clump.rb +2 -4
- data/lib/hashira/smells/duplicate_method_call.rb +3 -3
- data/lib/hashira/smells/feature_envy.rb +3 -3
- data/lib/hashira/smells/foreign.rb +14 -14
- data/lib/hashira/smells/instance_variable_assumption.rb +3 -3
- data/lib/hashira/smells/kind.rb +9 -0
- data/lib/hashira/smells/manual_dispatch.rb +1 -1
- data/lib/hashira/smells/module_initialize.rb +0 -2
- data/lib/hashira/smells/nil_check.rb +1 -1
- data/lib/hashira/smells/ownership.rb +19 -7
- data/lib/hashira/smells/param_check.rb +2 -2
- data/lib/hashira/smells/refs.rb +13 -7
- data/lib/hashira/smells/repeated_conditional.rb +1 -3
- data/lib/hashira/smells/report.rb +16 -7
- data/lib/hashira/smells/too_many_instance_variables.rb +3 -3
- data/lib/hashira/smells/visibility.rb +18 -11
- data/lib/hashira/trees.rb +26 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +14 -0
- metadata +16 -1
|
@@ -8,6 +8,7 @@ class Hashira::CI::FindingDiffReport
|
|
|
8
8
|
|
|
9
9
|
def print(diff)
|
|
10
10
|
introduced(diff.added).each { emit(it) }
|
|
11
|
+
aggravated(diff.worsened).each { |finding, before, after| regressed(finding, before, after) }
|
|
11
12
|
Hashira::CI::Improvement.new("Findings resolved", io: @io).print(diff.removed)
|
|
12
13
|
end
|
|
13
14
|
|
|
@@ -15,6 +16,15 @@ class Hashira::CI::FindingDiffReport
|
|
|
15
16
|
|
|
16
17
|
def introduced(added) = added.filter_map { |signature| @findings.find { it.signature == signature } }
|
|
17
18
|
|
|
19
|
+
def aggravated(worsened)
|
|
20
|
+
worsened.map { |signature, before, after| [@findings.find { it.signature == signature }, before, after] }
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def regressed(finding, before, after)
|
|
24
|
+
@io.puts("WORSE FINDING (was #{before}, now #{after}):")
|
|
25
|
+
Hashira::Report::FindingLines.new(finding, indent: " ", io: @io).emit
|
|
26
|
+
end
|
|
27
|
+
|
|
18
28
|
def emit(finding)
|
|
19
29
|
@io.puts("NEW FINDING:")
|
|
20
30
|
Hashira::Report::FindingLines.new(finding, indent: " ", io: @io).emit
|
data/lib/hashira/ci/gate.rb
CHANGED
|
@@ -1,10 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::CI::Gate
|
|
4
|
-
def initialize(findings, kinds, io: $stdout)
|
|
4
|
+
def initialize(findings, kinds, io: $stdout, error: $stderr)
|
|
5
5
|
@findings = findings
|
|
6
6
|
@kinds = kinds
|
|
7
7
|
@io = io
|
|
8
|
+
@error = error
|
|
8
9
|
end
|
|
9
10
|
|
|
10
11
|
def check
|
|
@@ -16,10 +17,17 @@ class Hashira::CI::Gate
|
|
|
16
17
|
|
|
17
18
|
def failure(offending)
|
|
18
19
|
offending.each { Hashira::Report::FindingLines.new(it, io: @io).print }
|
|
19
|
-
|
|
20
|
+
verdict = "Gate FAILED: #{offending.size} finding(s) — #{tally(offending)}."
|
|
21
|
+
@io.puts("\n#{verdict}")
|
|
22
|
+
@error.puts(verdict)
|
|
20
23
|
1
|
|
21
24
|
end
|
|
22
25
|
|
|
26
|
+
def tally(offending)
|
|
27
|
+
offending.group_by(&:kind).map { |kind, list| [kind, list.size] }
|
|
28
|
+
.sort_by { |kind, size| [-size, kind] }.map { |kind, size| "#{kind} #{size}" }.join(", ")
|
|
29
|
+
end
|
|
30
|
+
|
|
23
31
|
def clean
|
|
24
32
|
@io.puts("Gate OK: no findings of kind #{@kinds.join(", ")}.")
|
|
25
33
|
0
|
|
@@ -9,6 +9,6 @@ class Hashira::CI::Improvement
|
|
|
9
9
|
def print(removed)
|
|
10
10
|
return if removed.empty?
|
|
11
11
|
@io.puts("#{@label} (improvement!): #{removed.join(", ")}")
|
|
12
|
-
@io.puts("Lock it in:
|
|
12
|
+
@io.puts("Lock it in: re-run this command with --update-baseline")
|
|
13
13
|
end
|
|
14
14
|
end
|
data/lib/hashira/ci/ratchet.rb
CHANGED
|
@@ -1,30 +1,57 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::CI::Ratchet
|
|
4
|
-
def initialize(graph, findings,
|
|
4
|
+
def initialize(graph, findings, baseline, io: $stdout)
|
|
5
5
|
@graph = graph
|
|
6
6
|
@findings = findings
|
|
7
|
-
@baseline =
|
|
7
|
+
@baseline = baseline
|
|
8
8
|
@io = io
|
|
9
9
|
end
|
|
10
10
|
|
|
11
11
|
def update
|
|
12
|
-
@baseline.write(edges,
|
|
13
|
-
@io.puts("Baseline updated: #{edges.size} edges, #{
|
|
14
|
-
|
|
12
|
+
@baseline.write(edges, scored, packaging:)
|
|
13
|
+
@io.puts("Baseline updated: #{edges.size} edges, #{recorded}.")
|
|
14
|
+
Hashira::CI::Status::CLEAN
|
|
15
15
|
end
|
|
16
16
|
|
|
17
|
-
def check
|
|
17
|
+
def check(focus)
|
|
18
|
+
compared = focus.narrowing? ? Hashira::CI::Slice.new(@baseline) : Hashira::CI::Sweep.new(@baseline)
|
|
19
|
+
Hashira::CI::RatchetReport.new(@graph, @findings, io: @io)
|
|
20
|
+
.print(compared.edges(drift), delta(compared), tally(compared))
|
|
21
|
+
end
|
|
18
22
|
|
|
19
23
|
def blocker
|
|
20
24
|
return "no baseline at #{@baseline.path} — run --update-baseline first" unless @baseline.exist?
|
|
21
|
-
|
|
25
|
+
drifted.first
|
|
22
26
|
end
|
|
23
27
|
|
|
24
28
|
private
|
|
25
29
|
|
|
30
|
+
def drifted = [(mismatch unless @baseline.packaging == packaging), *narrowed(@baseline.wanted)].compact
|
|
31
|
+
|
|
32
|
+
def narrowed(wanted)
|
|
33
|
+
[
|
|
34
|
+
widened("the analyzers", @baseline.analyzers, wanted[:analyzers]),
|
|
35
|
+
widened("the directories", @baseline.targets, wanted[:targets])
|
|
36
|
+
]
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def widened(flag, was, now)
|
|
40
|
+
return if was == now
|
|
41
|
+
phrase(flag, was, now)
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def phrase(flag, was, now)
|
|
45
|
+
"baseline #{@baseline.path} was recorded with #{flag} #{was.join(", ")}, but this run " \
|
|
46
|
+
"uses #{now.join(", ")} — rerun the recorded way, or refresh it with --update-baseline"
|
|
47
|
+
end
|
|
48
|
+
|
|
26
49
|
def packaging = @graph.packaging.to_s
|
|
27
50
|
|
|
51
|
+
def recorded = collapsed(@findings.size, scored.size)
|
|
52
|
+
|
|
53
|
+
def collapsed(total, keys) = keys == total ? "#{total} findings" : "#{total} findings (#{keys} distinct)"
|
|
54
|
+
|
|
28
55
|
def mismatch
|
|
29
56
|
recorded = @baseline.packaging
|
|
30
57
|
"baseline #{@baseline.path} was recorded with --package-by #{recorded}, but this run " \
|
|
@@ -33,13 +60,17 @@ class Hashira::CI::Ratchet
|
|
|
33
60
|
|
|
34
61
|
def edges = @graph.edges.map(&:to_s)
|
|
35
62
|
|
|
36
|
-
def
|
|
63
|
+
def tally(compared) = compared.counts("#{@graph.edges.size} edges", "#{@findings.size} findings").join(", ")
|
|
64
|
+
|
|
65
|
+
def scored
|
|
66
|
+
@_scored ||= @findings.group_by(&:signature).sort.to_h { |key, group| [key, group.filter_map(&:magnitude).max] }
|
|
67
|
+
end
|
|
37
68
|
|
|
38
69
|
def drift = Hashira::CI::Diff.new(added: fresh, removed: @baseline.edges - edges)
|
|
39
70
|
|
|
40
71
|
def fresh = @graph.edges.reject { @baseline.edges.include?(it.to_s) }
|
|
41
72
|
|
|
42
|
-
def delta
|
|
43
|
-
|
|
73
|
+
def delta(compared)
|
|
74
|
+
compared.findings(scored) if @baseline.findings?
|
|
44
75
|
end
|
|
45
76
|
end
|
|
@@ -7,22 +7,24 @@ class Hashira::CI::RatchetReport
|
|
|
7
7
|
@io = io
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def print(edges, findings)
|
|
11
|
-
return unchanged if quiet?(edges, findings)
|
|
10
|
+
def print(edges, findings, tally)
|
|
11
|
+
return unchanged(tally) if quiet?(edges, findings)
|
|
12
12
|
details(edges, findings)
|
|
13
13
|
advice(edges, findings)
|
|
14
|
-
|
|
14
|
+
verdict(edges, findings)
|
|
15
15
|
end
|
|
16
16
|
|
|
17
17
|
private
|
|
18
18
|
|
|
19
19
|
def quiet?(*diffs) = diffs.compact.all?(&:empty?)
|
|
20
20
|
|
|
21
|
-
def unchanged
|
|
22
|
-
@io.puts("Ratchet OK: #{
|
|
23
|
-
|
|
21
|
+
def unchanged(tally)
|
|
22
|
+
@io.puts("Ratchet OK: #{tally}, unchanged.")
|
|
23
|
+
Hashira::CI::Status::CLEAN
|
|
24
24
|
end
|
|
25
25
|
|
|
26
|
+
def verdict(*diffs) = diffs.compact.any?(&:worse?) ? Hashira::CI::Status::WORSE : Hashira::CI::Status::BETTER
|
|
27
|
+
|
|
26
28
|
def details(edges, findings)
|
|
27
29
|
Hashira::CI::EdgeDiffReport.new(@graph, io: @io).print(edges)
|
|
28
30
|
Hashira::CI::FindingDiffReport.new(@findings, io: @io).print(findings) if findings
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::CI::Slice
|
|
4
|
+
SETTLED = Hashira::CI::Diff.new(added: [], removed: [])
|
|
5
|
+
|
|
6
|
+
def initialize(baseline)
|
|
7
|
+
@baseline = baseline
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def edges(_diff) = SETTLED
|
|
11
|
+
|
|
12
|
+
def findings(scored) = Hashira::CI::Comparison.new(scored, seen(scored)).diff
|
|
13
|
+
|
|
14
|
+
def seen(scored) = @baseline.findings.slice(*scored.keys)
|
|
15
|
+
|
|
16
|
+
def counts(_edges, findings) = [findings]
|
|
17
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::CI::Sweep
|
|
4
|
+
def initialize(baseline)
|
|
5
|
+
@baseline = baseline
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def edges(diff) = diff
|
|
9
|
+
|
|
10
|
+
def findings(scored) = Hashira::CI::Comparison.new(scored, @baseline.findings).diff
|
|
11
|
+
|
|
12
|
+
def counts(edges, findings) = [edges, findings]
|
|
13
|
+
end
|
|
@@ -1,23 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::CLI::Arguments
|
|
4
|
-
def initialize(
|
|
5
|
-
@
|
|
4
|
+
def initialize(given)
|
|
5
|
+
@given = given
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def take(flag, default)
|
|
9
|
-
position =
|
|
9
|
+
position = argv.index(flag)
|
|
10
10
|
return default unless position
|
|
11
|
-
_flag, value =
|
|
12
|
-
|
|
11
|
+
_flag, value = argv.slice!(position, 2)
|
|
12
|
+
vet(flag, value)
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def vet(flag, value)
|
|
16
|
+
raise(Hashira::Error, "#{flag} needs a value") if value.to_s.strip.empty?
|
|
17
|
+
raise(Hashira::Error, "#{flag} given more than once") if argv.include?(flag)
|
|
13
18
|
value
|
|
14
19
|
end
|
|
15
20
|
|
|
16
|
-
def delete(flag) =
|
|
21
|
+
def delete(flag) = argv.delete(flag)
|
|
17
22
|
|
|
18
23
|
def rest
|
|
19
|
-
stray =
|
|
24
|
+
stray = argv.find { it.start_with?("-") }
|
|
20
25
|
raise(Hashira::Error, "unknown option #{stray}") if stray
|
|
21
|
-
|
|
26
|
+
argv
|
|
22
27
|
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def argv = @_argv ||= @given.dup
|
|
23
32
|
end
|
|
@@ -2,23 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
class Hashira::CLI::CommandLine
|
|
4
4
|
def initialize(argv)
|
|
5
|
-
@
|
|
5
|
+
@argv = argv
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
def options = page || parsed
|
|
9
9
|
|
|
10
10
|
private
|
|
11
11
|
|
|
12
|
+
def arguments = @_arguments ||= Hashira::CLI::Arguments.new(@argv)
|
|
13
|
+
|
|
12
14
|
def page
|
|
13
15
|
flag = Hashira::CLI::FLAGS.select(&:page?).find { seen?(it) }
|
|
14
|
-
Hashira::CLI::Options.
|
|
16
|
+
Hashira::CLI::Options.build(flag.mode) if flag
|
|
15
17
|
end
|
|
16
18
|
|
|
17
|
-
def seen?(flag) = flag.names.any? {
|
|
19
|
+
def seen?(flag) = flag.names.any? { arguments.delete(it) }
|
|
18
20
|
|
|
19
21
|
def parsed
|
|
20
|
-
parses = Hashira::CLI::FLAGS.reject(&:page?).to_h { [it, it.read(
|
|
21
|
-
Hashira::CLI::Options.new(directories:
|
|
22
|
+
parses = Hashira::CLI::FLAGS.reject(&:page?).to_h { [it, it.read(arguments)] }
|
|
23
|
+
Hashira::CLI::Options.new(directories: arguments.rest, mode: mode(parses), **fields(parses))
|
|
24
|
+
.tap { Hashira::CLI::Needs.check(it) }
|
|
22
25
|
end
|
|
23
26
|
|
|
24
27
|
def fields(parses) = parses.to_h { |flag, value| [flag.field, value] }.except(nil)
|
data/lib/hashira/cli/fail_on.rb
CHANGED
|
@@ -5,20 +5,30 @@ require_relative "../pipeline"
|
|
|
5
5
|
module Hashira::CLI::FailOn
|
|
6
6
|
MEASURES = (Hashira::Pipeline::ANALYZERS - %i[coupling smells]).map(&:to_s).freeze
|
|
7
7
|
|
|
8
|
-
KINDS = {
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
8
|
+
KINDS = { "cycles" => "cycle", "sdp" => "sdp_violation", "dupe" => "duplication" }
|
|
9
|
+
.merge(Hashira::Pipeline::STRUCTURAL.to_h { [it, it] })
|
|
10
|
+
.merge(MEASURES.to_h { [it, it] })
|
|
11
|
+
.merge("smells" => Hashira::Pipeline::SMELLS)
|
|
12
|
+
.merge(Hashira::Pipeline::SMELLS.to_h { [it, it] })
|
|
13
|
+
.freeze
|
|
14
|
+
|
|
15
|
+
OWNERS = {
|
|
16
|
+
**Hashira::Pipeline::STRUCTURAL.to_h { [it, :coupling] },
|
|
17
|
+
**MEASURES.to_h { [it, it.to_sym] },
|
|
18
|
+
**Hashira::Pipeline::SMELLS.to_h { [it, :smells] }
|
|
13
19
|
}.freeze
|
|
14
20
|
|
|
15
21
|
module_function
|
|
16
22
|
|
|
17
23
|
def parse(list)
|
|
18
|
-
return []
|
|
19
|
-
list.split(",").flat_map { Array(kind(it.strip)) }.uniq
|
|
24
|
+
return [] if list.to_s.empty?
|
|
25
|
+
kinds = list.split(",").flat_map { Array(kind(it.strip)) }.uniq
|
|
26
|
+
raise(Hashira::Error, "--fail-on needs at least one kind") if kinds.empty?
|
|
27
|
+
kinds
|
|
20
28
|
end
|
|
21
29
|
|
|
30
|
+
def owner(kind) = OWNERS.fetch(kind)
|
|
31
|
+
|
|
22
32
|
def kind(name)
|
|
23
33
|
KINDS.fetch(name) do
|
|
24
34
|
raise(Hashira::Error, "unknown --fail-on kind #{name.inspect} (use: #{KINDS.keys.join(", ")})")
|
data/lib/hashira/cli/flag.rb
CHANGED
|
@@ -1,9 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::CLI
|
|
4
|
+
BLANK = %i[arg field parse mode].to_h { [it, nil] }.freeze
|
|
5
|
+
|
|
4
6
|
Flag =
|
|
5
7
|
Data.define(:name, :arg, :default, :field, :parse, :mode, :text) do
|
|
6
|
-
def initialize(**attributes) = super(
|
|
8
|
+
def initialize(**attributes) = super(**BLANK, default: "", **attributes)
|
|
7
9
|
|
|
8
10
|
def read(arguments) = arg ? take(arguments) : arguments.delete(name)
|
|
9
11
|
|
data/lib/hashira/cli/flags.rb
CHANGED
|
@@ -3,8 +3,10 @@
|
|
|
3
3
|
require_relative "fail_on"
|
|
4
4
|
require_relative "flag"
|
|
5
5
|
require_relative "format"
|
|
6
|
+
require_relative "only"
|
|
6
7
|
require_relative "package_by"
|
|
7
8
|
require_relative "skip"
|
|
9
|
+
require_relative "top"
|
|
8
10
|
|
|
9
11
|
class Hashira::CLI
|
|
10
12
|
FLAGS = [
|
|
@@ -13,6 +15,10 @@ class Hashira::CLI
|
|
|
13
15
|
text: ["text (default), json, dot, or mermaid"]
|
|
14
16
|
),
|
|
15
17
|
Flag.new(name: "--json", mode: :json, text: ["shorthand for --format json"]),
|
|
18
|
+
Flag.new(
|
|
19
|
+
name: "--compact", field: :compact,
|
|
20
|
+
text: ["emit JSON on one line, without the indentation", "meant for reading"]
|
|
21
|
+
),
|
|
16
22
|
Flag.new(
|
|
17
23
|
name: "--fail-on", arg: "KINDS", field: :fail_on, parse: FailOn, mode: :fail_on,
|
|
18
24
|
text: [
|
|
@@ -26,6 +32,23 @@ class Hashira::CLI
|
|
|
26
32
|
name: "--skip", arg: "ANALYZERS", field: :skip, parse: Skip,
|
|
27
33
|
text: ["drop an analyzer; comma-separated: coupling,", "complexity, duplication, smells"]
|
|
28
34
|
),
|
|
35
|
+
Flag.new(
|
|
36
|
+
name: "--only", arg: "PATHS", field: :only, parse: Only,
|
|
37
|
+
text: [
|
|
38
|
+
"keep only the findings that name these files;",
|
|
39
|
+
"comma-separated. The whole project is still read,",
|
|
40
|
+
"so cross-file signals stay right. Meant for hooks:",
|
|
41
|
+
"hashira --only $CHANGED --ratchet"
|
|
42
|
+
]
|
|
43
|
+
),
|
|
44
|
+
Flag.new(
|
|
45
|
+
name: "--top", arg: "N", field: :top, parse: Top,
|
|
46
|
+
text: [
|
|
47
|
+
"show at most N rows in each table and N",
|
|
48
|
+
"findings (default: 25 packages and findings,",
|
|
49
|
+
"10 methods and files)"
|
|
50
|
+
]
|
|
51
|
+
),
|
|
29
52
|
Flag.new(
|
|
30
53
|
name: "--package-by", arg: "WHAT", field: :packaging, parse: PackageBy,
|
|
31
54
|
text: [
|
data/lib/hashira/cli/format.rb
CHANGED
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fail_on"
|
|
4
|
+
|
|
5
|
+
module Hashira::CLI::Needs
|
|
6
|
+
DIAGRAMS = %i[dot mermaid].freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def check(options)
|
|
11
|
+
mode = options.mode
|
|
12
|
+
skip = options.skip
|
|
13
|
+
drawing(mode, skip)
|
|
14
|
+
gate(options.fail_on, skip)
|
|
15
|
+
shaping(options, mode)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def shaping(options, mode)
|
|
19
|
+
compacting(mode) if options.compact
|
|
20
|
+
focusing(mode) unless options.only.empty?
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def focusing(mode)
|
|
24
|
+
raise(Hashira::Error, "--only narrows the findings, but --update-baseline records them all") if mode == :update
|
|
25
|
+
return unless DIAGRAMS.include?(mode)
|
|
26
|
+
raise(Hashira::Error, "--format #{mode} draws the coupling graph, which --only cannot narrow")
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def compacting(mode)
|
|
30
|
+
raise(Hashira::Error, "--compact shapes JSON, but this run emits #{mode}") unless mode == :json
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def drawing(mode, skip)
|
|
34
|
+
return unless DIAGRAMS.include?(mode) && skip.include?(:coupling)
|
|
35
|
+
raise(Hashira::Error, "--format #{mode} draws the coupling graph, but --skip coupling drops it")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def gate(fail_on, skip)
|
|
39
|
+
blind = fail_on.find { skip.include?(owner(it)) }
|
|
40
|
+
return unless blind
|
|
41
|
+
raise(Hashira::Error, "--fail-on #{blind} needs the #{owner(blind)} analyzer, but --skip drops it")
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def owner(kind) = Hashira::CLI::FailOn.owner(kind)
|
|
45
|
+
end
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira::CLI::Only
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def parse(value)
|
|
7
|
+
return [] if value.empty?
|
|
8
|
+
value.split(",").map { vet(it.strip) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def vet(path)
|
|
12
|
+
here = path.delete_prefix("#{Dir.pwd}/").delete_prefix("./")
|
|
13
|
+
raise(Hashira::Error, "--only #{path.inspect} is not a file here") unless File.file?(here)
|
|
14
|
+
here
|
|
15
|
+
end
|
|
16
|
+
end
|
data/lib/hashira/cli/options.rb
CHANGED
|
@@ -2,13 +2,22 @@
|
|
|
2
2
|
|
|
3
3
|
class Hashira::CLI
|
|
4
4
|
Options =
|
|
5
|
-
Data.define(:directories, :mode, :baseline, :fail_on, :skip, :packaging) do
|
|
5
|
+
Data.define(:directories, :mode, :baseline, :fail_on, :skip, :only, :packaging, :top, :compact) do
|
|
6
6
|
def self.parse(argv) = CommandLine.new(argv).options
|
|
7
7
|
|
|
8
|
-
def self.
|
|
8
|
+
def self.build(mode)
|
|
9
|
+
new(
|
|
10
|
+
directories: [], baseline: "", fail_on: [], skip: [], only: [],
|
|
11
|
+
packaging: :auto, top: nil, compact: nil, mode:
|
|
12
|
+
)
|
|
13
|
+
end
|
|
9
14
|
|
|
10
15
|
def pipeline
|
|
11
|
-
Hashira::
|
|
16
|
+
chosen = Hashira::Project.new(directories)
|
|
17
|
+
told = Hashira::Report::Notices.new
|
|
18
|
+
told.scanning(chosen.files.size)
|
|
19
|
+
told.rails if directories.empty? && File.exist?("config/application.rb")
|
|
20
|
+
Hashira::Pipeline.new(chosen, enabled: analyzers, packaging:, only:)
|
|
12
21
|
end
|
|
13
22
|
|
|
14
23
|
def analyzers = Hashira::Pipeline::ANALYZERS - skip
|
|
@@ -9,6 +9,6 @@ module Hashira::CLI::PackageBy
|
|
|
9
9
|
return :auto if value.empty?
|
|
10
10
|
choice = value.to_sym
|
|
11
11
|
return choice if CHOICES.include?(choice)
|
|
12
|
-
raise(Hashira::
|
|
12
|
+
raise(Hashira::CLI::Choice.unknown("--package-by", value, CHOICES))
|
|
13
13
|
end
|
|
14
14
|
end
|
data/lib/hashira/cli/run.rb
CHANGED
|
@@ -1,27 +1,39 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::CLI::Run
|
|
4
|
-
MODES = { update: :update, ratchet: :check, fail_on: :guard, json: :json, dot: :diagram, mermaid: :diagram }.freeze
|
|
5
|
-
|
|
6
4
|
def initialize(pipeline, options)
|
|
7
5
|
@pipeline = pipeline
|
|
8
6
|
@options = options
|
|
9
7
|
end
|
|
10
8
|
|
|
9
|
+
MODES = { update: :update, ratchet: :check, fail_on: :guard }
|
|
10
|
+
.merge(json: :json, dot: :diagram, mermaid: :diagram).freeze
|
|
11
|
+
|
|
11
12
|
def status = __send__(MODES.fetch(@options.mode, :text))
|
|
12
13
|
|
|
13
14
|
private
|
|
14
15
|
|
|
15
16
|
def graph = @pipeline.graph
|
|
16
17
|
|
|
17
|
-
def findings = @
|
|
18
|
+
def findings = @_findings ||= accepted.screen(@pipeline.findings)
|
|
19
|
+
|
|
20
|
+
def accepted
|
|
21
|
+
path = @options.baseline
|
|
22
|
+
stop = Hashira::CI::Baseline.new(path).trouble
|
|
23
|
+
raise(Hashira::Error, stop) if stop
|
|
24
|
+
Hashira::CI::Accepted.build(path)
|
|
25
|
+
end
|
|
18
26
|
|
|
19
|
-
def update
|
|
27
|
+
def update
|
|
28
|
+
ratchet.update
|
|
29
|
+
rescue SystemCallError => error
|
|
30
|
+
raise(Hashira::Error, "cannot write #{@options.baseline} (#{error.message})")
|
|
31
|
+
end
|
|
20
32
|
|
|
21
33
|
def check
|
|
22
34
|
stop = ratchet.blocker
|
|
23
35
|
raise(Hashira::Error, stop) if stop
|
|
24
|
-
ratchet.check
|
|
36
|
+
ratchet.check(@pipeline.focus)
|
|
25
37
|
end
|
|
26
38
|
|
|
27
39
|
def guard = gate.check
|
|
@@ -32,9 +44,25 @@ class Hashira::CLI::Run
|
|
|
32
44
|
|
|
33
45
|
def text = report(Hashira::Report::Text)
|
|
34
46
|
|
|
35
|
-
def report(kind)
|
|
47
|
+
def report(kind)
|
|
48
|
+
notices
|
|
49
|
+
kind.new(view).print
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def notices
|
|
53
|
+
told = Hashira::Report::Notices.new
|
|
54
|
+
told.churn(@pipeline.project.label) unless @pipeline.churn.history?
|
|
55
|
+
broken = @pipeline.unparsed
|
|
56
|
+
told.unparsed(broken.size, broken.first(3).join(", ")) unless broken.empty?
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def ratchet = @_ratchet ||= Hashira::CI::Ratchet.new(graph, findings.all, baseline)
|
|
60
|
+
|
|
61
|
+
def baseline
|
|
62
|
+
Hashira::CI::Baseline.new(@options.baseline, analyzers: @options.analyzers, targets:)
|
|
63
|
+
end
|
|
36
64
|
|
|
37
|
-
def
|
|
65
|
+
def targets = @pipeline.project.directories
|
|
38
66
|
|
|
39
67
|
def gate = Hashira::CI::Gate.new(findings, @options.fail_on)
|
|
40
68
|
|
|
@@ -44,7 +72,7 @@ class Hashira::CLI::Run
|
|
|
44
72
|
Hashira::Report::View.new(
|
|
45
73
|
project: @pipeline.project, graph: (graph if @pipeline.enabled?(:coupling)),
|
|
46
74
|
complexity: @pipeline.complexity, duplication: @pipeline.duplication,
|
|
47
|
-
hotspots: @pipeline.hotspots, findings:
|
|
75
|
+
hotspots: @pipeline.hotspots, findings:, top: @options.top, compact: @options.compact
|
|
48
76
|
)
|
|
49
77
|
end
|
|
50
78
|
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira::CLI::Top
|
|
4
|
+
WHOLE = /\A[1-9]\d*\z/
|
|
5
|
+
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def parse(value)
|
|
9
|
+
return if value.empty?
|
|
10
|
+
return Integer(value, 10) if value.match?(WHOLE)
|
|
11
|
+
raise(Hashira::Error, "--top #{value.inspect} is not a positive whole number")
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/hashira/cli/usage.rb
CHANGED
|
@@ -17,6 +17,10 @@ module Hashira::CLI::Usage
|
|
|
17
17
|
|
|
18
18
|
FOOTER = <<~TEXT
|
|
19
19
|
|
|
20
|
+
Exit codes: 0 clean · 1 findings or a regression · 2 misuse
|
|
21
|
+
(bad flags, missing directory, unusable baseline) · 3 an
|
|
22
|
+
improvement the baseline has not recorded · 70 internal error.
|
|
23
|
+
|
|
20
24
|
Findings accepted by design can be recorded in the baseline as
|
|
21
25
|
"accepted": [{"kind": "...", "package": "...", "reason": "..."}]
|
|
22
26
|
— they leave reports and gates, keeping a one-line reminder each.
|
|
@@ -38,7 +42,7 @@ module Hashira::CLI::Usage
|
|
|
38
42
|
end
|
|
39
43
|
|
|
40
44
|
def emit(output)
|
|
41
|
-
puts(output)
|
|
45
|
+
$stdout.puts(output)
|
|
42
46
|
0
|
|
43
47
|
end
|
|
44
48
|
end
|