hashira 0.5.1 → 0.7.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 +98 -0
- data/README.md +131 -56
- data/lib/hashira/analysis/finding.rb +4 -0
- data/lib/hashira/churn.rb +10 -2
- data/lib/hashira/ci/accepted.rb +1 -4
- data/lib/hashira/ci/baseline.rb +28 -5
- data/lib/hashira/ci/diff.rb +15 -4
- 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 +33 -8
- data/lib/hashira/ci/ratchet_report.rb +4 -2
- data/lib/hashira/ci/scope.rb +11 -0
- data/lib/hashira/ci/status.rb +7 -0
- data/lib/hashira/cli/arguments.rb +6 -1
- data/lib/hashira/cli/fail_on.rb +12 -2
- data/lib/hashira/cli/flags.rb +13 -0
- data/lib/hashira/cli/needs.rb +34 -0
- data/lib/hashira/cli/options.rb +14 -3
- data/lib/hashira/cli/run.rb +32 -5
- data/lib/hashira/cli/top.rb +13 -0
- data/lib/hashira/cli/usage.rb +5 -1
- data/lib/hashira/cli.rb +24 -5
- data/lib/hashira/coupling/metric.rb +7 -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/pipeline.rb +15 -19
- data/lib/hashira/project.rb +19 -5
- data/lib/hashira/report/columns.rb +48 -0
- data/lib/hashira/report/complexity_table.rb +12 -16
- data/lib/hashira/report/dependency_map.rb +1 -0
- data/lib/hashira/report/hotspot_table.rb +6 -14
- data/lib/hashira/report/json.rb +9 -2
- data/lib/hashira/report/metrics_table.rb +19 -20
- data/lib/hashira/report/notices.rb +37 -0
- data/lib/hashira/report/phrases.rb +2 -0
- data/lib/hashira/report/smell_phrases.rb +6 -0
- data/lib/hashira/report/text.rb +24 -9
- data/lib/hashira/report/view.rb +4 -1
- data/lib/hashira/smells/boundary_sprawl.rb +43 -0
- data/lib/hashira/smells/census.rb +4 -1
- data/lib/hashira/smells/contexts.rb +1 -1
- data/lib/hashira/smells/feature_envy.rb +3 -1
- data/lib/hashira/smells/foreign.rb +131 -0
- data/lib/hashira/smells/ownership.rb +55 -0
- data/lib/hashira/smells/report.rb +10 -2
- data/lib/hashira/trees.rb +22 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +6 -0
- metadata +17 -6
|
@@ -2,35 +2,31 @@
|
|
|
2
2
|
|
|
3
3
|
class Hashira::Report::ComplexityTable
|
|
4
4
|
TOP = 10
|
|
5
|
-
|
|
6
|
-
|
|
5
|
+
METHOD_HEADERS = %w[method Cog Calls Loc].freeze
|
|
6
|
+
CLASS_HEADERS = %w[class Cog Methods Peak].freeze
|
|
7
7
|
METHOD_TITLE = "Cognitive complexity — worst methods (Cog = how hard to read, Calls = message sends)"
|
|
8
8
|
CLASS_TITLE = "Per-class rollup (Cog total survives extract-method; Peak is the worst method it hides)"
|
|
9
9
|
|
|
10
|
-
def initialize(complexity, io: $stdout)
|
|
10
|
+
def initialize(complexity, top: TOP, io: $stdout)
|
|
11
11
|
@complexity = complexity
|
|
12
|
+
@top = top
|
|
12
13
|
@io = io
|
|
13
14
|
end
|
|
14
15
|
|
|
15
16
|
def print
|
|
16
|
-
section(@complexity.ranked,
|
|
17
|
-
section(@complexity.classes,
|
|
17
|
+
section(@complexity.ranked, METHOD_HEADERS, METHOD_TITLE)
|
|
18
|
+
section(@complexity.classes, CLASS_HEADERS, CLASS_TITLE)
|
|
18
19
|
end
|
|
19
20
|
|
|
20
21
|
private
|
|
21
22
|
|
|
22
|
-
def section(scores,
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
@io.puts
|
|
26
|
-
end
|
|
27
|
-
|
|
28
|
-
def heading(row_format, columns, title)
|
|
29
|
-
header = format(row_format, *columns)
|
|
23
|
+
def section(scores, headers, title)
|
|
24
|
+
rows = ranked(scores)
|
|
25
|
+
return if rows.empty?
|
|
30
26
|
@io.puts("#{title}:\n\n")
|
|
31
|
-
@io.
|
|
32
|
-
@io.puts
|
|
27
|
+
Hashira::Report::Columns.new(headers, rows.map(&:cells), io: @io).print
|
|
28
|
+
@io.puts
|
|
33
29
|
end
|
|
34
30
|
|
|
35
|
-
def ranked(scores) = scores.select { it.cognitive.positive? }.first(
|
|
31
|
+
def ranked(scores) = scores.select { it.cognitive.positive? }.first(@top)
|
|
36
32
|
end
|
|
@@ -2,32 +2,24 @@
|
|
|
2
2
|
|
|
3
3
|
class Hashira::Report::HotspotTable
|
|
4
4
|
TOP = 10
|
|
5
|
-
|
|
5
|
+
HEADERS = %w[file Cog Dup Churn Rank].freeze
|
|
6
6
|
|
|
7
|
-
def initialize(hotspots, io: $stdout)
|
|
7
|
+
def initialize(hotspots, top: TOP, io: $stdout)
|
|
8
8
|
@hotspots = hotspots
|
|
9
|
+
@top = top
|
|
9
10
|
@io = io
|
|
10
11
|
end
|
|
11
12
|
|
|
12
13
|
def print
|
|
13
14
|
return if ranked.empty?
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
@io.puts("Hotspots — cost × churn (where refactoring pays the most):\n\n")
|
|
16
|
+
Hashira::Report::Columns.new(HEADERS, ranked.map(&:cells), io: @io).print
|
|
16
17
|
legend
|
|
17
18
|
end
|
|
18
19
|
|
|
19
20
|
private
|
|
20
21
|
|
|
21
|
-
def ranked = @ranked ||= @hotspots.files.first(
|
|
22
|
-
|
|
23
|
-
def rows = ranked.each { @io.puts(format(ROW, *it.cells)) }
|
|
24
|
-
|
|
25
|
-
def heading
|
|
26
|
-
@io.puts("Hotspots — cost × churn (where refactoring pays the most):\n\n")
|
|
27
|
-
header = format(ROW, *%w[file Cog Dup Churn Rank])
|
|
28
|
-
@io.puts(header)
|
|
29
|
-
@io.puts("-" * header.length)
|
|
30
|
-
end
|
|
22
|
+
def ranked = @ranked ||= @hotspots.files.first(@top)
|
|
31
23
|
|
|
32
24
|
def legend
|
|
33
25
|
@io.puts("\nLegend: Cog cognitive complexity, Dup mass of the clones the file carries,")
|
data/lib/hashira/report/json.rb
CHANGED
|
@@ -8,14 +8,21 @@ class Hashira::Report::Json
|
|
|
8
8
|
@io = io
|
|
9
9
|
end
|
|
10
10
|
|
|
11
|
+
SCHEMA = 1
|
|
12
|
+
|
|
11
13
|
def print
|
|
12
|
-
@io.puts(JSON.pretty_generate(payload))
|
|
14
|
+
@io.puts(@view.compact ? JSON.generate(payload) : JSON.pretty_generate(payload))
|
|
13
15
|
0
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
private
|
|
17
19
|
|
|
18
|
-
def payload = base.merge(coupling).merge(sections.compact)
|
|
20
|
+
def payload = about.merge(base).merge(coupling).merge(sections.compact)
|
|
21
|
+
|
|
22
|
+
def about
|
|
23
|
+
project = @view.project
|
|
24
|
+
{ version: SCHEMA, packaging: @view.graph&.packaging, targets: project.directories, files: project.files.size }
|
|
25
|
+
end
|
|
19
26
|
|
|
20
27
|
def base = { findings: @view.findings.all.map { rendered(it) }, accepted: accepted }
|
|
21
28
|
|
|
@@ -1,47 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Report::MetricsTable
|
|
4
|
-
|
|
4
|
+
TOP = 25
|
|
5
|
+
|
|
6
|
+
def initialize(graph, top: TOP, io: $stdout)
|
|
5
7
|
@graph = graph
|
|
8
|
+
@top = top
|
|
6
9
|
@io = io
|
|
7
10
|
end
|
|
8
11
|
|
|
9
|
-
|
|
12
|
+
HEADERS = %w[package TC Ca Ce I Cyc].freeze
|
|
10
13
|
|
|
11
14
|
def print
|
|
12
|
-
|
|
13
|
-
kept.each { |package, metric| @io.puts(row(package, metric)) }
|
|
15
|
+
Hashira::Report::Columns.new(HEADERS, kept.map { |package, metric| row(package, metric) }, io: @io).print
|
|
14
16
|
note unless spared.empty?
|
|
17
|
+
more unless over.zero?
|
|
15
18
|
legend
|
|
16
19
|
end
|
|
17
20
|
|
|
18
21
|
private
|
|
19
22
|
|
|
20
|
-
def ranked = @graph.metrics.sort_by { |_package, metric| metric.
|
|
23
|
+
def ranked = @graph.metrics.sort_by { |_package, metric| metric.order }
|
|
21
24
|
|
|
22
|
-
def split = @split ||= (ranked.size >
|
|
25
|
+
def split = @split ||= (ranked.size > @top ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
|
|
23
26
|
|
|
24
|
-
def kept = split.first
|
|
27
|
+
def kept = split.first.first(@top)
|
|
25
28
|
|
|
26
29
|
def spared = split.last
|
|
27
30
|
|
|
31
|
+
def over = split.first.size - kept.size
|
|
32
|
+
|
|
28
33
|
def leaf?(metric) = metric.types <= 1 && metric.efferent.zero? && metric.afferent <= 1
|
|
29
34
|
|
|
30
|
-
def note
|
|
31
|
-
@io.puts(" + #{spared.size} single-type leaf packages (TC ≤ 1, Ca ≤ 1, Ce = 0) — hidden for brevity")
|
|
32
|
-
end
|
|
35
|
+
def note = @io.puts(" + #{leaves} (TC ≤ 1, Ca ≤ 1, Ce = 0) — hidden for brevity")
|
|
33
36
|
|
|
34
|
-
def
|
|
35
|
-
@io.puts(format("%-12s %3s %3s %3s %5s %-3s", *%w[package TC Ca Ce I Cyc]))
|
|
36
|
-
@io.puts("-" * 40)
|
|
37
|
-
end
|
|
37
|
+
def leaves = Hashira::Report::Phrases.count(spared.size, "single-type leaf package")
|
|
38
38
|
|
|
39
|
-
def
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
end
|
|
39
|
+
def more = @io.puts(" … and #{over} more — raise the cap with --top, or read them all with --json")
|
|
40
|
+
|
|
41
|
+
def row(package, metric) = [package, *metric.cells, cyc(package)]
|
|
42
|
+
|
|
43
|
+
def cyc(package) = @graph.cycles.through?(package) ? "YES" : "-"
|
|
45
44
|
|
|
46
45
|
def legend
|
|
47
46
|
@io.puts("\nLegend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),")
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Report::Notices
|
|
4
|
+
ISSUES = "https://github.com/giacope/hashira/issues"
|
|
5
|
+
|
|
6
|
+
def initialize(io: $stderr)
|
|
7
|
+
@io = io
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def scanning(files) = interactive { @io.puts("hashira: reading #{files} files…") }
|
|
11
|
+
|
|
12
|
+
def finished(files, seconds) = interactive { @io.puts("hashira: #{files} files in #{seconds}s") }
|
|
13
|
+
|
|
14
|
+
def rails
|
|
15
|
+
@io.puts("hashira: this looks like a Rails root — `hashira app` reads the application, not just lib/")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def churn(label)
|
|
19
|
+
@io.puts("hashira: no git history for #{label} — hotspots are ranked by cost alone")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def crashed(error)
|
|
23
|
+
@io.puts("hashira: internal error — #{error.class}: #{error.message}")
|
|
24
|
+
@io.puts(" at #{error.backtrace.first}")
|
|
25
|
+
@io.puts(" this is a bug in hashira — please report it at #{ISSUES}")
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def unparsed(count, sample)
|
|
29
|
+
@io.puts("hashira: #{count} of the files did not parse — #{sample}")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
private
|
|
33
|
+
|
|
34
|
+
def interactive
|
|
35
|
+
yield if @io.tty?
|
|
36
|
+
end
|
|
37
|
+
end
|
|
@@ -81,6 +81,8 @@ module Hashira::Report::Phrases
|
|
|
81
81
|
|
|
82
82
|
def score(value) = format("%.2f", value)
|
|
83
83
|
|
|
84
|
+
def count(number, noun) = "#{number} #{number == 1 ? noun : "#{noun}s"}"
|
|
85
|
+
|
|
84
86
|
def clause(part) = "#{part[:users].join(", ")} #{verb(part)} #{part[:constants].join(", ")}"
|
|
85
87
|
|
|
86
88
|
def verb(part)
|
|
@@ -19,6 +19,12 @@ module Hashira::Report::Phrases
|
|
|
19
19
|
"Name the result in a local variable."
|
|
20
20
|
end
|
|
21
21
|
|
|
22
|
+
def on_boundary_sprawl(finding)
|
|
23
|
+
detail = finding.detail
|
|
24
|
+
"#{detail[:count]} methods across #{detail[:files]} files each pick apart #{finding.package}'s " \
|
|
25
|
+
"internals. Front the boundary with one adapter the rest can lean on."
|
|
26
|
+
end
|
|
27
|
+
|
|
22
28
|
def on_feature_envy(finding)
|
|
23
29
|
detail = finding.detail
|
|
24
30
|
names = detail[:names]
|
data/lib/hashira/report/text.rb
CHANGED
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Report::Text
|
|
4
|
+
FINDINGS = 25
|
|
5
|
+
|
|
4
6
|
def initialize(view, io: $stdout)
|
|
5
7
|
@view = view
|
|
6
8
|
@io = io
|
|
@@ -19,31 +21,37 @@ class Hashira::Report::Text
|
|
|
19
21
|
def coupling
|
|
20
22
|
graph = @view.graph
|
|
21
23
|
header(graph)
|
|
22
|
-
Hashira::Report::MetricsTable
|
|
24
|
+
table(Hashira::Report::MetricsTable, graph)
|
|
23
25
|
Hashira::Report::DependencyMap.new(graph, io: @io).print
|
|
24
26
|
folded(graph.folds)
|
|
25
27
|
end
|
|
26
28
|
|
|
27
29
|
def folded(folds)
|
|
28
30
|
return if folds.empty?
|
|
29
|
-
@io.puts("
|
|
31
|
+
@io.puts("Folded (single-type classes joined to their base or domain):")
|
|
30
32
|
folds.each { @io.puts(" #{it[:from]} -> #{it[:to]} (#{it[:via]})") }
|
|
33
|
+
@io.puts
|
|
31
34
|
end
|
|
32
35
|
|
|
33
|
-
def
|
|
36
|
+
def table(kind, subject) = kind.new(subject, top: @view.top || kind::TOP, io: @io).print
|
|
37
|
+
|
|
38
|
+
def complexity = table(Hashira::Report::ComplexityTable, @view.complexity)
|
|
34
39
|
|
|
35
|
-
def hotspots = Hashira::Report::HotspotTable
|
|
40
|
+
def hotspots = table(Hashira::Report::HotspotTable, @view.hotspots)
|
|
36
41
|
|
|
37
42
|
def header(graph)
|
|
38
43
|
packages = graph.packages.size
|
|
39
|
-
@io.puts(banner(packages))
|
|
44
|
+
@io.puts(banner(graph.packaging, packages))
|
|
40
45
|
caveat if packages == 1
|
|
41
46
|
end
|
|
42
47
|
|
|
43
|
-
def banner(packages)
|
|
44
|
-
"Package (
|
|
48
|
+
def banner(grouping, packages)
|
|
49
|
+
"Package (#{grouping}) metrics for #{@view.project.label} " \
|
|
50
|
+
"(#{count(packages, "package")}, #{count(total, "file")})\n\n"
|
|
45
51
|
end
|
|
46
52
|
|
|
53
|
+
def count(number, noun) = Hashira::Report::Phrases.count(number, noun)
|
|
54
|
+
|
|
47
55
|
def total = @view.project.files.size
|
|
48
56
|
|
|
49
57
|
def caveat
|
|
@@ -55,7 +63,7 @@ class Hashira::Report::Text
|
|
|
55
63
|
|
|
56
64
|
def findings
|
|
57
65
|
all = @view.findings.all
|
|
58
|
-
@io.puts("
|
|
66
|
+
@io.puts("Findings (#{all.size}):")
|
|
59
67
|
list(all)
|
|
60
68
|
accepted
|
|
61
69
|
@io.puts("\n Full evidence + machine format: hashira --json") unless all.empty?
|
|
@@ -63,7 +71,14 @@ class Hashira::Report::Text
|
|
|
63
71
|
|
|
64
72
|
def list(all)
|
|
65
73
|
return @io.puts(" none ✓ — structure is healthy") if all.empty?
|
|
66
|
-
|
|
74
|
+
shown = all.first(@view.top || FINDINGS)
|
|
75
|
+
shown.each { Hashira::Report::FindingLines.new(it, indent: " ", io: @io).emit }
|
|
76
|
+
elided(all.size - shown.size)
|
|
77
|
+
end
|
|
78
|
+
|
|
79
|
+
def elided(rest)
|
|
80
|
+
return if rest.zero?
|
|
81
|
+
@io.puts(" … and #{rest} more — raise the cap with --top, or read them all with --json")
|
|
67
82
|
end
|
|
68
83
|
|
|
69
84
|
def accepted
|
data/lib/hashira/report/view.rb
CHANGED
|
@@ -2,6 +2,9 @@
|
|
|
2
2
|
|
|
3
3
|
module Hashira
|
|
4
4
|
module Report
|
|
5
|
-
View =
|
|
5
|
+
View =
|
|
6
|
+
Data.define(:project, :graph, :complexity, :duplication, :hotspots, :findings, :top, :compact) do
|
|
7
|
+
def initialize(top: nil, compact: nil, **) = super
|
|
8
|
+
end
|
|
6
9
|
end
|
|
7
10
|
end
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Smells::BoundarySprawl
|
|
4
|
+
KIND = "boundary_sprawl"
|
|
5
|
+
|
|
6
|
+
METHOD_FLOOR = 12
|
|
7
|
+
|
|
8
|
+
FILE_FLOOR = 3
|
|
9
|
+
|
|
10
|
+
SHOWN = 4
|
|
11
|
+
|
|
12
|
+
def initialize(subjects, ownership)
|
|
13
|
+
@subjects = subjects
|
|
14
|
+
@ownership = ownership
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def findings
|
|
18
|
+
reaches.filter_map { |root, contexts| finding(root, contexts) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def reaches
|
|
24
|
+
@subjects.each_with_object({}) do |subject, map|
|
|
25
|
+
Hashira::Smells::Foreign.new(subject, @ownership).reaches.each { (map[it] ||= []) << subject }
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def finding(root, contexts)
|
|
30
|
+
files = contexts.map(&:file).uniq
|
|
31
|
+
build(root, contexts, files) if wide?(contexts.size, files.size)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def wide?(methods, files) = methods >= METHOD_FLOOR && files >= FILE_FLOOR
|
|
35
|
+
|
|
36
|
+
def build(root, contexts, files)
|
|
37
|
+
Hashira::Analysis::Finding.new(
|
|
38
|
+
kind: KIND, package: root,
|
|
39
|
+
detail: { count: contexts.size, files: files.size },
|
|
40
|
+
evidence: contexts.first(SHOWN).map(&:site)
|
|
41
|
+
)
|
|
42
|
+
end
|
|
43
|
+
end
|
|
@@ -6,8 +6,11 @@ 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)
|
|
9
10
|
end
|
|
10
11
|
|
|
12
|
+
attr_reader :ownership
|
|
13
|
+
|
|
11
14
|
def types = @trees.flat_map { |path, tree| harvest(@project.relative(path), tree) }
|
|
12
15
|
|
|
13
16
|
private
|
|
@@ -26,7 +29,7 @@ class Hashira::Smells::Census
|
|
|
26
29
|
|
|
27
30
|
def defs(name, node, file)
|
|
28
31
|
Hashira::Smells::Visibility.new(node).entries.map do |def_node, section|
|
|
29
|
-
Hashira::Smells::MethodContext.new(owner: name, node: def_node, file:, section:)
|
|
32
|
+
Hashira::Smells::MethodContext.new(owner: name, node: def_node, file:, section:, ownership: @ownership)
|
|
30
33
|
end
|
|
31
34
|
end
|
|
32
35
|
end
|
|
@@ -32,7 +32,7 @@ module Hashira
|
|
|
32
32
|
end
|
|
33
33
|
|
|
34
34
|
MethodContext =
|
|
35
|
-
Data.define(:owner, :node, :file, :section) do
|
|
35
|
+
Data.define(:owner, :node, :file, :section, :ownership) do
|
|
36
36
|
def subject = "#{owner}#{singleton? ? "." : "#"}#{node.name}"
|
|
37
37
|
|
|
38
38
|
def line = node.location.start_line
|
|
@@ -9,7 +9,9 @@ class Hashira::Smells::FeatureEnvy < Hashira::Smells::Check
|
|
|
9
9
|
|
|
10
10
|
def refs = @refs ||= Hashira::Smells::Refs.new(subject.node)
|
|
11
11
|
|
|
12
|
-
def envied = @envied ||= refs.envious
|
|
12
|
+
def envied = @envied ||= refs.envious.reject { foreign.dismiss?(it) }
|
|
13
|
+
|
|
14
|
+
def foreign = @foreign ||= Hashira::Smells::Foreign.new(subject, subject.ownership)
|
|
13
15
|
|
|
14
16
|
def detail = { site:, names: envied }
|
|
15
17
|
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::Foreign
|
|
6
|
+
TYPE_TESTS = %i[is_a? kind_of? instance_of?].freeze
|
|
7
|
+
|
|
8
|
+
KEYED_READS = %i[[] fetch values_at dig key?].freeze
|
|
9
|
+
|
|
10
|
+
KEYS = [Prism::StringNode, Prism::SymbolNode].freeze
|
|
11
|
+
|
|
12
|
+
LITERALS = [Prism::HashNode, Prism::KeywordHashNode, Prism::ArrayNode, Prism::StringNode].freeze
|
|
13
|
+
|
|
14
|
+
STATE = [
|
|
15
|
+
Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode,
|
|
16
|
+
Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode,
|
|
17
|
+
Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableTargetNode
|
|
18
|
+
].freeze
|
|
19
|
+
|
|
20
|
+
def initialize(subject, ownership)
|
|
21
|
+
node = subject.node
|
|
22
|
+
@body = Hashira::Smells::Scope.inside(node)
|
|
23
|
+
@tail = tail(node)
|
|
24
|
+
@ownership = ownership
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def dismiss?(name)
|
|
28
|
+
convert? || fenced?(name) || wire?(name) || built?(name) ||
|
|
29
|
+
derived?(name) || rescued?(name)
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def reaches
|
|
33
|
+
tests { true }.reject { @ownership.owned?(it) }.map(&:first).uniq
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
private
|
|
37
|
+
|
|
38
|
+
def convert?
|
|
39
|
+
@tail.is_a?(Prism::CallNode) && @tail.name == :new &&
|
|
40
|
+
Hashira::Analysis::Syntax.segments(@tail.receiver).any? && stateless?
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def stateless? = @body.none? { STATE.include?(it.class) }
|
|
44
|
+
|
|
45
|
+
def fenced?(name)
|
|
46
|
+
tested = tests { it == name }
|
|
47
|
+
tested.any? && tested.none? { @ownership.owned?(it) }
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
def wire?(name)
|
|
51
|
+
calls = @body.grep(Prism::CallNode).select { |call| local?(call.receiver) { it == name } }
|
|
52
|
+
calls.any? && calls.all? { keyed?(it) } && reassignments(name).none?
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def built?(name)
|
|
56
|
+
writes(name).any? { LITERALS.include?(it.value.class) }
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def derived?(name)
|
|
60
|
+
writes(name).any? { spawned?(it.value) }
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def rescued?(name)
|
|
64
|
+
snares(name).any? { alien?(it.exceptions) }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def keyed?(call)
|
|
68
|
+
names = call.arguments&.arguments
|
|
69
|
+
KEYED_READS.include?(call.name) && names&.any? &&
|
|
70
|
+
names.all? { KEYS.include?(it.class) }
|
|
71
|
+
end
|
|
72
|
+
|
|
73
|
+
def writes(name) = among(Prism::LocalVariableWriteNode, name)
|
|
74
|
+
|
|
75
|
+
def reassignments(name) = among(Prism::LocalVariableOperatorWriteNode, name)
|
|
76
|
+
|
|
77
|
+
def among(kind, name)
|
|
78
|
+
@body.grep(kind).select { it.name == name }
|
|
79
|
+
end
|
|
80
|
+
|
|
81
|
+
def snares(name)
|
|
82
|
+
@body.grep(Prism::RescueNode).select { it.reference&.name == name }
|
|
83
|
+
end
|
|
84
|
+
|
|
85
|
+
def alien?(exceptions)
|
|
86
|
+
exceptions.map { Hashira::Analysis::Syntax.segments(it) }.none? { @ownership.owned?(it) }
|
|
87
|
+
end
|
|
88
|
+
|
|
89
|
+
def spawned?(value)
|
|
90
|
+
value.is_a?(Prism::CallNode) && stranger?(value.receiver)
|
|
91
|
+
end
|
|
92
|
+
|
|
93
|
+
def stranger?(node)
|
|
94
|
+
case node
|
|
95
|
+
when Prism::LocalVariableReadNode then fenced?(node.name)
|
|
96
|
+
when Prism::ConstantReadNode, Prism::ConstantPathNode then unowned?(node)
|
|
97
|
+
else false
|
|
98
|
+
end
|
|
99
|
+
end
|
|
100
|
+
|
|
101
|
+
def unowned?(node)
|
|
102
|
+
!@ownership.owned?(Hashira::Analysis::Syntax.segments(node))
|
|
103
|
+
end
|
|
104
|
+
|
|
105
|
+
def tests(&)
|
|
106
|
+
(probes(&) + arms(&)).map { Hashira::Analysis::Syntax.segments(it) }.reject(&:empty?) + lookups(&)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def probes(&)
|
|
110
|
+
@body.grep(Prism::CallNode).select { TYPE_TESTS.include?(it.name) && local?(it.receiver, &) }.filter_map { key(it) }
|
|
111
|
+
end
|
|
112
|
+
|
|
113
|
+
def arms(&)
|
|
114
|
+
@body.grep(Prism::CaseNode).select { local?(it.predicate, &) }.flat_map(&:conditions).flat_map(&:conditions)
|
|
115
|
+
end
|
|
116
|
+
|
|
117
|
+
def lookups(&)
|
|
118
|
+
@body.grep(Prism::CallNode).select { it.name == :[] && sorts?(key(it), &) }.flat_map { @ownership.keys(Hashira::Analysis::Syntax.segments(it.receiver)) }
|
|
119
|
+
end
|
|
120
|
+
|
|
121
|
+
def sorts?(argument, &)
|
|
122
|
+
argument.is_a?(Prism::CallNode) && argument.name == :class &&
|
|
123
|
+
local?(argument.receiver, &)
|
|
124
|
+
end
|
|
125
|
+
|
|
126
|
+
def local?(node, &) = node.is_a?(Prism::LocalVariableReadNode) && yield(node.name)
|
|
127
|
+
|
|
128
|
+
def key(call) = call.arguments&.arguments&.first
|
|
129
|
+
|
|
130
|
+
def tail(def_node) = Hashira::Analysis::Syntax.statements(def_node).compact.last
|
|
131
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Smells::Ownership
|
|
4
|
+
def initialize(trees)
|
|
5
|
+
@suffixes = Set.new
|
|
6
|
+
@tables = {}
|
|
7
|
+
trees.each { survey(it) }
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def owned?(segments) = @suffixes.include?(segments.join("::"))
|
|
11
|
+
|
|
12
|
+
def keys(segments) = @tables.fetch(segments.join("::"), [])
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def survey(tree)
|
|
17
|
+
Hashira::Analysis::TypeWalk.each(tree) do |node, full|
|
|
18
|
+
absorb(full)
|
|
19
|
+
Hashira::Analysis::Syntax.constants(node).each { record(full, it) }
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def record(full, constant)
|
|
24
|
+
path = full + [constant.name.to_s]
|
|
25
|
+
absorb(path)
|
|
26
|
+
chart(path, thaw(constant.value))
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def absorb(path)
|
|
30
|
+
@suffixes.merge(suffixes(path))
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def chart(path, value)
|
|
34
|
+
return unless value.is_a?(Prism::HashNode)
|
|
35
|
+
keys = value.elements.map { spine(it) }
|
|
36
|
+
return if keys.empty? || keys.any?(&:nil?)
|
|
37
|
+
suffixes(path).each { @tables[it] = keys }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def thaw(value)
|
|
41
|
+
frozen?(value) ? value.receiver : value
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def frozen?(value) = value.is_a?(Prism::CallNode) && value.name == :freeze
|
|
45
|
+
|
|
46
|
+
def spine(element)
|
|
47
|
+
return unless element.is_a?(Prism::AssocNode)
|
|
48
|
+
segments = Hashira::Analysis::Syntax.segments(element.key)
|
|
49
|
+
segments unless segments.empty?
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def suffixes(path)
|
|
53
|
+
path.each_index.map { path.drop(it).join("::") }
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "boundary_sprawl"
|
|
3
4
|
require_relative "check"
|
|
4
5
|
require_relative "control_parameter"
|
|
5
6
|
require_relative "data_clump"
|
|
6
7
|
require_relative "duplicate_method_call"
|
|
7
8
|
require_relative "feature_envy"
|
|
9
|
+
require_relative "foreign"
|
|
8
10
|
require_relative "instance_variable_assumption"
|
|
9
11
|
require_relative "manual_dispatch"
|
|
10
12
|
require_relative "module_initialize"
|
|
11
13
|
require_relative "nil_check"
|
|
14
|
+
require_relative "ownership"
|
|
12
15
|
require_relative "repeated_conditional"
|
|
13
16
|
require_relative "too_many_instance_variables"
|
|
14
17
|
require_relative "utility_function"
|
|
@@ -21,13 +24,18 @@ class Hashira::Smells::Report
|
|
|
21
24
|
PROBES = CHECKS.reject(&:judge?).freeze
|
|
22
25
|
|
|
23
26
|
def initialize(project, trees)
|
|
24
|
-
@
|
|
27
|
+
@census = Hashira::Smells::Census.new(project, trees)
|
|
28
|
+
@types = @census.types
|
|
25
29
|
end
|
|
26
30
|
|
|
27
|
-
def findings = @findings ||= sniff(@types, JUDGES) + sniff(
|
|
31
|
+
def findings = @findings ||= sniff(@types, JUDGES) + sniff(methods, PROBES) + sprawl
|
|
28
32
|
|
|
29
33
|
private
|
|
30
34
|
|
|
35
|
+
def methods = @types.flat_map(&:defs)
|
|
36
|
+
|
|
37
|
+
def sprawl = Hashira::Smells::BoundarySprawl.new(methods, @census.ownership).findings
|
|
38
|
+
|
|
31
39
|
def sniff(subjects, checks) = subjects.flat_map { |subject| verdicts(subject, checks) }
|
|
32
40
|
|
|
33
41
|
def verdicts(subject, checks) = checks.filter_map { |check| check.new(subject).finding }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Trees
|
|
6
|
+
def initialize(project)
|
|
7
|
+
@unparsed = []
|
|
8
|
+
@all = project.files.to_h { [it, parse(it)] }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
attr_reader :all, :unparsed
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def parse(path)
|
|
16
|
+
result = Prism.parse_file(path)
|
|
17
|
+
@unparsed << path if result.failure?
|
|
18
|
+
result.value
|
|
19
|
+
rescue SystemCallError => error
|
|
20
|
+
raise(Hashira::Error, "cannot read #{path} (#{error.message})")
|
|
21
|
+
end
|
|
22
|
+
end
|