hashira 0.7.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 +38 -0
- data/README.md +29 -0
- data/exe/hashira +1 -1
- data/lib/hashira/analysis/finding.rb +1 -1
- data/lib/hashira/analysis/syntax.rb +6 -6
- data/lib/hashira/churn.rb +1 -10
- data/lib/hashira/ci/accepted.rb +9 -7
- data/lib/hashira/ci/baseline.rb +34 -30
- data/lib/hashira/ci/comparison.rb +24 -0
- data/lib/hashira/ci/diff.rb +0 -15
- data/lib/hashira/ci/gate.rb +3 -3
- data/lib/hashira/ci/ratchet.rb +10 -4
- data/lib/hashira/ci/ratchet_report.rb +4 -4
- data/lib/hashira/ci/scope.rb +0 -1
- data/lib/hashira/ci/slice.rb +17 -0
- data/lib/hashira/ci/sweep.rb +13 -0
- data/lib/hashira/cli/arguments.rb +12 -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 +6 -6
- data/lib/hashira/cli/flag.rb +3 -1
- data/lib/hashira/cli/flags.rb +10 -0
- data/lib/hashira/cli/format.rb +1 -1
- data/lib/hashira/cli/needs.rb +13 -2
- data/lib/hashira/cli/only.rb +16 -0
- data/lib/hashira/cli/options.rb +8 -10
- data/lib/hashira/cli/package_by.rb +1 -1
- data/lib/hashira/cli/run.rb +9 -8
- data/lib/hashira/cli.rb +22 -21
- 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 +3 -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/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 +22 -14
- data/lib/hashira/project.rb +35 -34
- data/lib/hashira/report/columns.rb +14 -8
- data/lib/hashira/report/graph_payload.rb +1 -1
- data/lib/hashira/report/hotspot_table.rb +1 -1
- data/lib/hashira/report/json.rb +2 -1
- data/lib/hashira/report/metrics_table.rb +3 -3
- data/lib/hashira/report/phrases.rb +23 -18
- 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 +8 -4
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +8 -0
- metadata +9 -1
data/lib/hashira/project.rb
CHANGED
|
@@ -3,40 +3,15 @@
|
|
|
3
3
|
class Hashira::Project
|
|
4
4
|
ROOT_PACKAGE = "(root)"
|
|
5
5
|
|
|
6
|
-
def
|
|
7
|
-
|
|
6
|
+
def initialize(requested)
|
|
7
|
+
@requested = requested
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def
|
|
11
|
-
return ["lib"] if Dir["lib/*/"].any? || Dir["lib/*.rb"].any?
|
|
12
|
-
raise(Hashira::Error, "no lib/ directory here — pass the source directory explicitly")
|
|
13
|
-
end
|
|
14
|
-
private_class_method :defaults
|
|
15
|
-
|
|
16
|
-
def self.descend(directory)
|
|
17
|
-
child = sole(directory)
|
|
18
|
-
return directory unless child && Dir["#{child}/*/"].any? && (Dir["#{directory}/*.rb"] - ["#{child}.rb"]).empty?
|
|
19
|
-
descend(child)
|
|
20
|
-
end
|
|
10
|
+
def directories = @_directories ||= resolved
|
|
21
11
|
|
|
22
|
-
def
|
|
23
|
-
subdirectories = Dir["#{directory}/*/"]
|
|
24
|
-
subdirectories.size == 1 ? subdirectories.first.delete_suffix("/") : nil
|
|
25
|
-
end
|
|
26
|
-
private_class_method :descend, :sole
|
|
27
|
-
|
|
28
|
-
def initialize(directories)
|
|
29
|
-
vet(directories)
|
|
30
|
-
@directories = distinct(directories.map { it.delete_suffix("/") })
|
|
31
|
-
raise(Hashira::Error, "no Ruby files under #{label}") if files.empty?
|
|
32
|
-
@rails = @directories.any? { config?(File.expand_path(it)) }
|
|
33
|
-
end
|
|
34
|
-
|
|
35
|
-
attr_reader :directories
|
|
12
|
+
def rails? = directories.any? { config?(File.expand_path(it)) }
|
|
36
13
|
|
|
37
|
-
def
|
|
38
|
-
|
|
39
|
-
def files = @directories.flat_map { Dir["#{it}/**/*.rb"] }.sort
|
|
14
|
+
def files = directories.flat_map { Dir["#{it}/**/*.rb"] }.sort
|
|
40
15
|
|
|
41
16
|
def package(path)
|
|
42
17
|
first, rest = relative(path).delete_suffix(".rb").split("/", 2)
|
|
@@ -46,12 +21,38 @@ class Hashira::Project
|
|
|
46
21
|
|
|
47
22
|
def relative(path) = path.delete_prefix("#{parent(path)}/")
|
|
48
23
|
|
|
49
|
-
def label =
|
|
24
|
+
def label = directories.join(", ")
|
|
50
25
|
|
|
51
26
|
def root = ROOT_PACKAGE
|
|
52
27
|
|
|
53
28
|
private
|
|
54
29
|
|
|
30
|
+
def resolved
|
|
31
|
+
chosen = (@requested.empty? ? defaults : @requested).map { descend(it.delete_suffix("/")) }
|
|
32
|
+
vet(chosen)
|
|
33
|
+
survivors = distinct(chosen)
|
|
34
|
+
raise(Hashira::Error, "no Ruby files under #{survivors.join(", ")}") if bare?(survivors)
|
|
35
|
+
survivors
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def bare?(survivors) = survivors.flat_map { Dir["#{it}/**/*.rb"] }.empty?
|
|
39
|
+
|
|
40
|
+
def defaults
|
|
41
|
+
return ["lib"] if Dir["lib/*/"].any? || Dir["lib/*.rb"].any?
|
|
42
|
+
raise(Hashira::Error, "no lib/ directory here — pass the source directory explicitly")
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def descend(directory)
|
|
46
|
+
child = sole(directory)
|
|
47
|
+
return directory unless child && Dir["#{child}/*/"].any? && (Dir["#{directory}/*.rb"] - ["#{child}.rb"]).empty?
|
|
48
|
+
descend(child)
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def sole(directory)
|
|
52
|
+
subdirectories = Dir["#{directory}/*/"]
|
|
53
|
+
subdirectories.size == 1 ? subdirectories.first.delete_suffix("/") : nil
|
|
54
|
+
end
|
|
55
|
+
|
|
55
56
|
def vet(directories)
|
|
56
57
|
file = directories.find { File.file?(it) }
|
|
57
58
|
raise(Hashira::Error, "#{file} is a file — hashira takes directories (try: hashira #{File.dirname(file)})") if file
|
|
@@ -73,13 +74,13 @@ class Hashira::Project
|
|
|
73
74
|
def folder?(path, name) = Dir.exist?("#{parent(path)}/#{name}")
|
|
74
75
|
|
|
75
76
|
def contested
|
|
76
|
-
@
|
|
77
|
-
|
|
77
|
+
@_contested ||=
|
|
78
|
+
directories.flat_map { |directory| Dir["#{directory}/*/"].map { File.basename(it) } }
|
|
78
79
|
.tally.filter_map { |name, count| name if count > 1 }
|
|
79
80
|
end
|
|
80
81
|
|
|
81
82
|
def parent(path)
|
|
82
|
-
|
|
83
|
+
directories.find { path.start_with?("#{it}/") } ||
|
|
83
84
|
raise(Hashira::Error, "#{path} is outside the analyzed directories")
|
|
84
85
|
end
|
|
85
86
|
end
|
|
@@ -7,7 +7,7 @@ class Hashira::Report::Columns
|
|
|
7
7
|
NUMBER = /\A-?\d+(?:\.\d+)?\z/
|
|
8
8
|
|
|
9
9
|
def initialize(headers, rows, io: $stdout)
|
|
10
|
-
@
|
|
10
|
+
@raw = [headers, *rows]
|
|
11
11
|
@io = io
|
|
12
12
|
end
|
|
13
13
|
|
|
@@ -17,14 +17,20 @@ class Hashira::Report::Columns
|
|
|
17
17
|
body
|
|
18
18
|
end
|
|
19
19
|
|
|
20
|
-
def body =
|
|
20
|
+
def body = rows.each { @io.puts(line(it)) }
|
|
21
21
|
|
|
22
|
-
def header = line(
|
|
22
|
+
def header = line(headers)
|
|
23
23
|
|
|
24
|
-
def rule = "-" * [header,
|
|
24
|
+
def rule = "-" * [header, *rows.map { line(it) }].map(&:length).max
|
|
25
25
|
|
|
26
26
|
private
|
|
27
27
|
|
|
28
|
+
def clipped = @_clipped ||= @raw.map { |cells| cells.map { clip(it) } }
|
|
29
|
+
|
|
30
|
+
def headers = clipped.first
|
|
31
|
+
|
|
32
|
+
def rows = clipped.drop(1)
|
|
33
|
+
|
|
28
34
|
def line(cells) = cells.map.with_index { |cell, column| pad(cell, column) }.join(GAP).rstrip
|
|
29
35
|
|
|
30
36
|
def pad(cell, column)
|
|
@@ -32,13 +38,13 @@ class Hashira::Report::Columns
|
|
|
32
38
|
numeric[column] ? cell.rjust(width) : cell.ljust(width)
|
|
33
39
|
end
|
|
34
40
|
|
|
35
|
-
def widths = @
|
|
41
|
+
def widths = @_widths ||= headers.each_index.map { |column| down(column).map(&:length).max }
|
|
36
42
|
|
|
37
|
-
def numeric = @
|
|
43
|
+
def numeric = @_numeric ||= headers.each_index.map { |column| rows.any? && counted?(column) }
|
|
38
44
|
|
|
39
|
-
def counted?(column) =
|
|
45
|
+
def counted?(column) = rows.all? { NUMBER.match?(it[column]) }
|
|
40
46
|
|
|
41
|
-
def down(column) = [
|
|
47
|
+
def down(column) = [headers[column], *rows.map { it[column] }]
|
|
42
48
|
|
|
43
49
|
def clip(cell)
|
|
44
50
|
text = cell.to_s
|
|
@@ -17,7 +17,7 @@ class Hashira::Report::GraphPayload
|
|
|
17
17
|
|
|
18
18
|
def edges
|
|
19
19
|
@graph.weighted.map do |from, to, weight|
|
|
20
|
-
{ from:, to:, weight
|
|
20
|
+
{ from:, to:, weight: }.merge(refs: @graph.evidence(from, to).to_a.sort)
|
|
21
21
|
end
|
|
22
22
|
end
|
|
23
23
|
end
|
|
@@ -19,7 +19,7 @@ class Hashira::Report::HotspotTable
|
|
|
19
19
|
|
|
20
20
|
private
|
|
21
21
|
|
|
22
|
-
def ranked = @
|
|
22
|
+
def ranked = @_ranked ||= @hotspots.files.first(@top)
|
|
23
23
|
|
|
24
24
|
def legend
|
|
25
25
|
@io.puts("\nLegend: Cog cognitive complexity, Dup mass of the clones the file carries,")
|
data/lib/hashira/report/json.rb
CHANGED
|
@@ -21,7 +21,8 @@ class Hashira::Report::Json
|
|
|
21
21
|
|
|
22
22
|
def about
|
|
23
23
|
project = @view.project
|
|
24
|
-
{ version: SCHEMA, packaging: @view.graph&.packaging, targets: project.directories
|
|
24
|
+
{ version: SCHEMA, packaging: @view.graph&.packaging, targets: project.directories }
|
|
25
|
+
.merge(files: project.files.size)
|
|
25
26
|
end
|
|
26
27
|
|
|
27
28
|
def base = { findings: @view.findings.all.map { rendered(it) }, accepted: accepted }
|
|
@@ -22,7 +22,7 @@ class Hashira::Report::MetricsTable
|
|
|
22
22
|
|
|
23
23
|
def ranked = @graph.metrics.sort_by { |_package, metric| metric.order }
|
|
24
24
|
|
|
25
|
-
def split = @
|
|
25
|
+
def split = @_split ||= (ranked.size > @top ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
|
|
26
26
|
|
|
27
27
|
def kept = split.first.first(@top)
|
|
28
28
|
|
|
@@ -38,9 +38,9 @@ class Hashira::Report::MetricsTable
|
|
|
38
38
|
|
|
39
39
|
def more = @io.puts(" … and #{over} more — raise the cap with --top, or read them all with --json")
|
|
40
40
|
|
|
41
|
-
def row(package, metric) = [package, *metric.cells,
|
|
41
|
+
def row(package, metric) = [package, *metric.cells, cycle(package)]
|
|
42
42
|
|
|
43
|
-
def
|
|
43
|
+
def cycle(package) = @graph.cycles.through?(package) ? "YES" : "-"
|
|
44
44
|
|
|
45
45
|
def legend
|
|
46
46
|
@io.puts("\nLegend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),")
|
|
@@ -1,28 +1,33 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Hashira::Report::Phrases
|
|
4
|
+
FLATTEN = "flatten the branching — guard clauses, early returns, or polymorphism."
|
|
5
|
+
|
|
6
|
+
LOOP_BODY = "extract the loop body into its own method."
|
|
7
|
+
|
|
4
8
|
COMPLEXITY_ADVICE = {
|
|
5
|
-
"if" =>
|
|
6
|
-
"elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch."
|
|
7
|
-
|
|
9
|
+
"if" => FLATTEN, "else" => FLATTEN,
|
|
10
|
+
"elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch."
|
|
11
|
+
}.merge(
|
|
8
12
|
"case" => "a case this size often wants polymorphism or a dispatch table.",
|
|
9
13
|
"boolean" => "name the compound condition in a predicate method.",
|
|
10
|
-
"rescue" => "narrow the rescue, or lift error handling to the caller."
|
|
11
|
-
|
|
12
|
-
"
|
|
13
|
-
|
|
14
|
+
"rescue" => "narrow the rescue, or lift error handling to the caller."
|
|
15
|
+
).merge(
|
|
16
|
+
"while" => LOOP_BODY, "until" => LOOP_BODY, "for" => LOOP_BODY
|
|
17
|
+
).merge(
|
|
14
18
|
"unless" => "invert to a guard clause or a named predicate.",
|
|
15
19
|
"ternary" => "extract the nested ternary into a named method."
|
|
16
|
-
|
|
20
|
+
).freeze
|
|
17
21
|
|
|
18
22
|
DUPLICATION_ADVICE = {
|
|
19
23
|
identical: "byte-for-byte identical — extract a shared method and call it from each site.",
|
|
20
24
|
literal: "differs only in literal values — extract a method, pass them as arguments.",
|
|
21
|
-
message: "differs only in the receiver or message — extract a method taking the receiver."
|
|
25
|
+
message: "differs only in the receiver or message — extract a method taking the receiver."
|
|
26
|
+
}.merge(
|
|
22
27
|
constant: "differs only in a constant — extract a method and parameterize it.",
|
|
23
28
|
structure: "the control flow differs — extract the common core, but verify by hand (lower confidence).",
|
|
24
29
|
mixed: "extract the shared shape and pass what differs as parameters."
|
|
25
|
-
|
|
30
|
+
).freeze
|
|
26
31
|
|
|
27
32
|
module_function
|
|
28
33
|
|
|
@@ -38,9 +43,9 @@ module Hashira::Report::Phrases
|
|
|
38
43
|
|
|
39
44
|
def on_sdp_violation(finding)
|
|
40
45
|
detail = finding.detail
|
|
41
|
-
from, to = detail.
|
|
42
|
-
"#{from} (I=#{score(detail
|
|
43
|
-
"(I=#{score(detail
|
|
46
|
+
from, to = detail.deconstruct
|
|
47
|
+
"#{from} (I=#{score(detail.from_instability)}) depends on the LESS stable #{to} " \
|
|
48
|
+
"(I=#{score(detail.to_instability)}) — churn in #{to} will force churn in #{from}. " \
|
|
44
49
|
"Invert the edge or extract the stable part of #{to} that #{from} needs."
|
|
45
50
|
end
|
|
46
51
|
|
|
@@ -69,14 +74,14 @@ module Hashira::Report::Phrases
|
|
|
69
74
|
|
|
70
75
|
def on_complexity(finding)
|
|
71
76
|
detail = finding.detail
|
|
72
|
-
"#{finding.package} — cognitive #{detail
|
|
73
|
-
"(#{detail
|
|
77
|
+
"#{finding.package} — cognitive #{detail.cognitive}, #{detail.calls} calls " \
|
|
78
|
+
"(#{detail.site}). #{COMPLEXITY_ADVICE.fetch(detail.dominant)}"
|
|
74
79
|
end
|
|
75
80
|
|
|
76
81
|
def on_duplication(finding)
|
|
77
82
|
detail = finding.detail
|
|
78
|
-
"#{detail
|
|
79
|
-
"#{DUPLICATION_ADVICE.fetch(detail
|
|
83
|
+
"#{detail.size} similar fragments (mass #{detail.mass}) — " \
|
|
84
|
+
"#{DUPLICATION_ADVICE.fetch(detail.kind)}#{footnote(detail)}"
|
|
80
85
|
end
|
|
81
86
|
|
|
82
87
|
def score(value) = format("%.2f", value)
|
|
@@ -95,6 +100,6 @@ module Hashira::Report::Phrases
|
|
|
95
100
|
end
|
|
96
101
|
|
|
97
102
|
def footnote(detail)
|
|
98
|
-
detail
|
|
103
|
+
detail.hot ? " Both sites change often — fix one, miss the other." : ""
|
|
99
104
|
end
|
|
100
105
|
end
|
|
@@ -6,10 +6,9 @@ class Hashira::Smells::Census
|
|
|
6
6
|
def initialize(project, trees)
|
|
7
7
|
@project = project
|
|
8
8
|
@trees = trees
|
|
9
|
-
@ownership = Hashira::Smells::Ownership.new(trees.values)
|
|
10
9
|
end
|
|
11
10
|
|
|
12
|
-
|
|
11
|
+
def ownership = @_ownership ||= Hashira::Smells::Ownership.new(@trees.values)
|
|
13
12
|
|
|
14
13
|
def types = @trees.flat_map { |path, tree| harvest(@project.relative(path), tree) }
|
|
15
14
|
|
|
@@ -28,8 +27,8 @@ class Hashira::Smells::Census
|
|
|
28
27
|
def kind(node) = node.is_a?(Prism::ModuleNode) ? :module : :class
|
|
29
28
|
|
|
30
29
|
def defs(name, node, file)
|
|
31
|
-
Hashira::Smells::Visibility.new(node).entries.map do |
|
|
32
|
-
Hashira::Smells::MethodContext.new(owner: name, node:
|
|
30
|
+
Hashira::Smells::Visibility.new(node).entries.map do |definition, section|
|
|
31
|
+
Hashira::Smells::MethodContext.new(owner: name, node: definition, file:, section:, ownership:)
|
|
33
32
|
end
|
|
34
33
|
end
|
|
35
34
|
end
|
data/lib/hashira/smells/check.rb
CHANGED
|
@@ -1,10 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Smells::Check
|
|
4
|
-
def self.kind = name.split("::").last.gsub(/(?<=[a-z])(?=[A-Z])/, "_").downcase
|
|
5
|
-
|
|
6
|
-
def self.judge? = false
|
|
7
|
-
|
|
8
4
|
def initialize(subject)
|
|
9
5
|
@subject = subject
|
|
10
6
|
end
|
|
@@ -18,7 +14,7 @@ class Hashira::Smells::Check
|
|
|
18
14
|
|
|
19
15
|
attr_reader :subject
|
|
20
16
|
|
|
21
|
-
def kind = self.class.
|
|
17
|
+
def kind = Hashira::Smells::Kind.new(self.class).to_s
|
|
22
18
|
|
|
23
19
|
def label = subject.subject
|
|
24
20
|
|
|
@@ -9,12 +9,12 @@ module Hashira
|
|
|
9
9
|
|
|
10
10
|
module_function
|
|
11
11
|
|
|
12
|
-
def names(
|
|
12
|
+
def names(definition) = parts(definition).flat_map { expand(it) }
|
|
13
13
|
|
|
14
|
-
def arguments(
|
|
14
|
+
def arguments(definition) = parts(definition).grep_v(Prism::BlockParameterNode).flat_map { expand(it) }
|
|
15
15
|
|
|
16
|
-
def parts(
|
|
17
|
-
node =
|
|
16
|
+
def parts(definition)
|
|
17
|
+
node = definition.parameters
|
|
18
18
|
return [] unless node
|
|
19
19
|
node.requireds + node.optionals + node.posts + node.keywords +
|
|
20
20
|
[node.rest, node.keyword_rest, node.block].compact
|
|
@@ -5,7 +5,7 @@ class Hashira::Smells::ControlParameter < Hashira::Smells::Check
|
|
|
5
5
|
|
|
6
6
|
def smelly? = culprits.any?
|
|
7
7
|
|
|
8
|
-
def culprits = @
|
|
8
|
+
def culprits = @_culprits ||= subject.parameters.filter_map { |name| culprit(name) }
|
|
9
9
|
|
|
10
10
|
def culprit(name)
|
|
11
11
|
lines = spots(name).uniq
|
|
@@ -5,15 +5,13 @@ class Hashira::Smells::DataClump < Hashira::Smells::Check
|
|
|
5
5
|
|
|
6
6
|
MIN_SIZE = 2
|
|
7
7
|
|
|
8
|
-
def self.judge? = true
|
|
9
|
-
|
|
10
8
|
private
|
|
11
9
|
|
|
12
10
|
def smelly? = clumps.any?
|
|
13
11
|
|
|
14
|
-
def candidates = @
|
|
12
|
+
def candidates = @_candidates ||= subject.owned.map { [it, it.arguments.sort] }
|
|
15
13
|
|
|
16
|
-
def clumps = @
|
|
14
|
+
def clumps = @_clumps ||= shared.map { |clump| [clump, holders(clump)] }
|
|
17
15
|
|
|
18
16
|
def shared = candidates.combination(MAX_COPIES + 1).filter_map { |group| clump(group) }.uniq
|
|
19
17
|
|
|
@@ -9,14 +9,14 @@ class Hashira::Smells::DuplicateMethodCall < Hashira::Smells::Check
|
|
|
9
9
|
|
|
10
10
|
def smelly? = repeats.any?
|
|
11
11
|
|
|
12
|
-
def calls = @
|
|
12
|
+
def calls = @_calls ||= Hashira::Smells::Scope.inside(subject.node).grep(Prism::CallNode)
|
|
13
13
|
|
|
14
14
|
def plain?(node)
|
|
15
15
|
!node.receiver && !node.arguments && !node.block.is_a?(Prism::BlockArgumentNode)
|
|
16
16
|
end
|
|
17
17
|
|
|
18
18
|
def repeats
|
|
19
|
-
@
|
|
19
|
+
@_repeats ||= usual.reject { |_handle, nodes| whole.value?(nodes) }.merge(whole)
|
|
20
20
|
end
|
|
21
21
|
|
|
22
22
|
def usual
|
|
@@ -25,7 +25,7 @@ class Hashira::Smells::DuplicateMethodCall < Hashira::Smells::Check
|
|
|
25
25
|
end
|
|
26
26
|
|
|
27
27
|
def whole
|
|
28
|
-
@
|
|
28
|
+
@_whole ||=
|
|
29
29
|
calls.select { it.block.is_a?(Prism::BlockNode) }
|
|
30
30
|
.group_by { it.slice.gsub(/\s+/, " ") }.select { |_handle, group| group.size > LIMIT }
|
|
31
31
|
end
|
|
@@ -7,11 +7,11 @@ class Hashira::Smells::FeatureEnvy < Hashira::Smells::Check
|
|
|
7
7
|
!subject.singleton? && !subject.mixin? && refs.ego.positive? && envied.any?
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def refs = @
|
|
10
|
+
def refs = @_refs ||= Hashira::Smells::Refs.new(subject.node)
|
|
11
11
|
|
|
12
|
-
def envied = @
|
|
12
|
+
def envied = @_envied ||= refs.envious.reject { foreign.dismiss?(it) }
|
|
13
13
|
|
|
14
|
-
def foreign = @
|
|
14
|
+
def foreign = @_foreign ||= Hashira::Smells::Foreign.new(subject, subject.ownership)
|
|
15
15
|
|
|
16
16
|
def detail = { site:, names: envied }
|
|
17
17
|
|
|
@@ -18,9 +18,7 @@ class Hashira::Smells::Foreign
|
|
|
18
18
|
].freeze
|
|
19
19
|
|
|
20
20
|
def initialize(subject, ownership)
|
|
21
|
-
|
|
22
|
-
@body = Hashira::Smells::Scope.inside(node)
|
|
23
|
-
@tail = tail(node)
|
|
21
|
+
@subject = subject
|
|
24
22
|
@ownership = ownership
|
|
25
23
|
end
|
|
26
24
|
|
|
@@ -35,12 +33,16 @@ class Hashira::Smells::Foreign
|
|
|
35
33
|
|
|
36
34
|
private
|
|
37
35
|
|
|
36
|
+
def body = @_body ||= Hashira::Smells::Scope.inside(@subject.node)
|
|
37
|
+
|
|
38
|
+
def tail = @_tail ||= Hashira::Analysis::Syntax.statements(@subject.node).compact.last
|
|
39
|
+
|
|
38
40
|
def convert?
|
|
39
|
-
|
|
40
|
-
Hashira::Analysis::Syntax.segments(
|
|
41
|
+
tail.is_a?(Prism::CallNode) && tail.name == :new &&
|
|
42
|
+
Hashira::Analysis::Syntax.segments(tail.receiver).any? && stateless?
|
|
41
43
|
end
|
|
42
44
|
|
|
43
|
-
def stateless? =
|
|
45
|
+
def stateless? = body.none? { STATE.include?(it.class) }
|
|
44
46
|
|
|
45
47
|
def fenced?(name)
|
|
46
48
|
tested = tests { it == name }
|
|
@@ -48,7 +50,7 @@ class Hashira::Smells::Foreign
|
|
|
48
50
|
end
|
|
49
51
|
|
|
50
52
|
def wire?(name)
|
|
51
|
-
calls =
|
|
53
|
+
calls = body.grep(Prism::CallNode).select { |call| local?(call.receiver) { it == name } }
|
|
52
54
|
calls.any? && calls.all? { keyed?(it) } && reassignments(name).none?
|
|
53
55
|
end
|
|
54
56
|
|
|
@@ -75,11 +77,11 @@ class Hashira::Smells::Foreign
|
|
|
75
77
|
def reassignments(name) = among(Prism::LocalVariableOperatorWriteNode, name)
|
|
76
78
|
|
|
77
79
|
def among(kind, name)
|
|
78
|
-
|
|
80
|
+
body.grep(kind).select { it.name == name }
|
|
79
81
|
end
|
|
80
82
|
|
|
81
83
|
def snares(name)
|
|
82
|
-
|
|
84
|
+
body.grep(Prism::RescueNode).select { it.reference&.name == name }
|
|
83
85
|
end
|
|
84
86
|
|
|
85
87
|
def alien?(exceptions)
|
|
@@ -107,15 +109,15 @@ class Hashira::Smells::Foreign
|
|
|
107
109
|
end
|
|
108
110
|
|
|
109
111
|
def probes(&)
|
|
110
|
-
|
|
112
|
+
body.grep(Prism::CallNode).select { TYPE_TESTS.include?(it.name) && local?(it.receiver, &) }.filter_map { key(it) }
|
|
111
113
|
end
|
|
112
114
|
|
|
113
115
|
def arms(&)
|
|
114
|
-
|
|
116
|
+
body.grep(Prism::CaseNode).select { local?(it.predicate, &) }.flat_map(&:conditions).flat_map(&:conditions)
|
|
115
117
|
end
|
|
116
118
|
|
|
117
119
|
def lookups(&)
|
|
118
|
-
|
|
120
|
+
body.grep(Prism::CallNode).select { it.name == :[] && sorts?(key(it), &) }.flat_map { @ownership.keys(Hashira::Analysis::Syntax.segments(it.receiver)) }
|
|
119
121
|
end
|
|
120
122
|
|
|
121
123
|
def sorts?(argument, &)
|
|
@@ -126,6 +128,4 @@ class Hashira::Smells::Foreign
|
|
|
126
128
|
def local?(node, &) = node.is_a?(Prism::LocalVariableReadNode) && yield(node.name)
|
|
127
129
|
|
|
128
130
|
def key(call) = call.arguments&.arguments&.first
|
|
129
|
-
|
|
130
|
-
def tail(def_node) = Hashira::Analysis::Syntax.statements(def_node).compact.last
|
|
131
131
|
end
|
|
@@ -26,13 +26,13 @@ class Hashira::Smells::InstanceVariableAssumption < Hashira::Smells::Check
|
|
|
26
26
|
end
|
|
27
27
|
end
|
|
28
28
|
|
|
29
|
-
def self.judge? = true
|
|
30
|
-
|
|
31
29
|
private
|
|
32
30
|
|
|
33
31
|
def smelly? = subject.kind == :class && assumed.any?
|
|
34
32
|
|
|
35
|
-
def assumed = @
|
|
33
|
+
def assumed = @_assumed ||= (read - prepared).uniq.sort.reject { cache?(it) }
|
|
34
|
+
|
|
35
|
+
def cache?(name) = name.start_with?("@_")
|
|
36
36
|
|
|
37
37
|
def read = subject.owned.flat_map { Harvest.reads(it.node) }
|
|
38
38
|
|
|
@@ -8,7 +8,7 @@ class Hashira::Smells::ManualDispatch < Hashira::Smells::Check
|
|
|
8
8
|
def smelly? = sightings.any?
|
|
9
9
|
|
|
10
10
|
def sightings
|
|
11
|
-
@
|
|
11
|
+
@_sightings ||= Hashira::Smells::Scope.inside(subject.node)
|
|
12
12
|
.select { it.is_a?(Prism::CallNode) && it.name == :respond_to? }
|
|
13
13
|
end
|
|
14
14
|
|
|
@@ -9,7 +9,7 @@ class Hashira::Smells::NilCheck < Hashira::Smells::Check
|
|
|
9
9
|
|
|
10
10
|
def smelly? = checks.any?
|
|
11
11
|
|
|
12
|
-
def checks = @
|
|
12
|
+
def checks = @_checks ||= Hashira::Smells::Scope.inside(subject.node).select { check?(it) }
|
|
13
13
|
|
|
14
14
|
def check?(node)
|
|
15
15
|
case node
|
|
@@ -2,17 +2,29 @@
|
|
|
2
2
|
|
|
3
3
|
class Hashira::Smells::Ownership
|
|
4
4
|
def initialize(trees)
|
|
5
|
-
@
|
|
6
|
-
@tables = {}
|
|
7
|
-
trees.each { survey(it) }
|
|
5
|
+
@trees = trees
|
|
8
6
|
end
|
|
9
7
|
|
|
10
|
-
def owned?(segments)
|
|
8
|
+
def owned?(segments)
|
|
9
|
+
surveyed
|
|
10
|
+
@_suffixes.include?(segments.join("::"))
|
|
11
|
+
end
|
|
11
12
|
|
|
12
|
-
def keys(segments)
|
|
13
|
+
def keys(segments)
|
|
14
|
+
surveyed
|
|
15
|
+
@_tables.fetch(segments.join("::"), [])
|
|
16
|
+
end
|
|
13
17
|
|
|
14
18
|
private
|
|
15
19
|
|
|
20
|
+
def surveyed
|
|
21
|
+
return if @_surveyed
|
|
22
|
+
@_surveyed = true
|
|
23
|
+
@_suffixes = Set.new
|
|
24
|
+
@_tables = {}
|
|
25
|
+
@trees.each { survey(it) }
|
|
26
|
+
end
|
|
27
|
+
|
|
16
28
|
def survey(tree)
|
|
17
29
|
Hashira::Analysis::TypeWalk.each(tree) do |node, full|
|
|
18
30
|
absorb(full)
|
|
@@ -27,14 +39,14 @@ class Hashira::Smells::Ownership
|
|
|
27
39
|
end
|
|
28
40
|
|
|
29
41
|
def absorb(path)
|
|
30
|
-
@
|
|
42
|
+
@_suffixes.merge(suffixes(path))
|
|
31
43
|
end
|
|
32
44
|
|
|
33
45
|
def chart(path, value)
|
|
34
46
|
return unless value.is_a?(Prism::HashNode)
|
|
35
47
|
keys = value.elements.map { spine(it) }
|
|
36
48
|
return if keys.empty? || keys.any?(&:nil?)
|
|
37
|
-
suffixes(path).each { @
|
|
49
|
+
suffixes(path).each { @_tables[it] = keys }
|
|
38
50
|
end
|
|
39
51
|
|
|
40
52
|
def thaw(value)
|
|
@@ -22,13 +22,13 @@ class Hashira::Smells::ParamCheck
|
|
|
22
22
|
private
|
|
23
23
|
|
|
24
24
|
def nested
|
|
25
|
-
@
|
|
25
|
+
@_nested ||= Hashira::Smells::Conditions.nested(branches).map { self.class.new(it, @name) }
|
|
26
26
|
end
|
|
27
27
|
|
|
28
28
|
def branches = Hashira::Smells::Conditions.branches(@node)
|
|
29
29
|
|
|
30
30
|
def predicate
|
|
31
|
-
@
|
|
31
|
+
@_predicate ||= spread(Hashira::Smells::Conditions.condition(@node))
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
def spread(condition) = condition ? Hashira::Analysis::NodeWalk.collect(condition) : []
|