hashira 0.3.0 → 0.5.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 +155 -0
- data/README.md +127 -25
- data/lib/hashira/analysis/finding.rb +7 -6
- data/lib/hashira/analysis/node_walk.rb +3 -3
- data/lib/hashira/analysis/syntax.rb +21 -6
- data/lib/hashira/analysis/type_walk.rb +7 -9
- data/lib/hashira/churn.rb +11 -13
- data/lib/hashira/ci/accepted.rb +27 -31
- data/lib/hashira/ci/baseline.rb +22 -24
- data/lib/hashira/ci/diff.rb +8 -7
- data/lib/hashira/ci/edge_diff_report.rb +13 -17
- data/lib/hashira/ci/finding_diff_report.rb +14 -18
- data/lib/hashira/ci/gate.rb +19 -23
- data/lib/hashira/ci/improvement.rb +9 -14
- data/lib/hashira/ci/ratchet.rb +32 -27
- data/lib/hashira/ci/ratchet_report.rb +26 -32
- data/lib/hashira/cli/arguments.rb +23 -0
- data/lib/hashira/cli/command_line.rb +55 -71
- data/lib/hashira/cli/fail_on.rb +24 -19
- data/lib/hashira/cli/options.rb +9 -4
- data/lib/hashira/cli/package_by.rb +14 -0
- data/lib/hashira/cli/run.rb +33 -27
- data/lib/hashira/cli/skip.rb +14 -20
- data/lib/hashira/cli/usage.rb +44 -41
- data/lib/hashira/cli.rb +17 -19
- data/lib/hashira/complexity/boolean_run.rb +13 -15
- data/lib/hashira/complexity/cognitive_score.rb +68 -66
- data/lib/hashira/complexity/if_chain.rb +33 -38
- data/lib/hashira/complexity/method_finding.rb +20 -42
- data/lib/hashira/complexity/method_score.rb +9 -7
- data/lib/hashira/complexity/rescue_scan.rb +16 -21
- data/lib/hashira/complexity/rollup.rb +13 -15
- data/lib/hashira/complexity/scores.rb +44 -0
- data/lib/hashira/coupling/audiences.rb +49 -0
- data/lib/hashira/coupling/catalog.rb +27 -0
- data/lib/hashira/coupling/census.rb +55 -0
- data/lib/hashira/coupling/constant_registry.rb +47 -0
- data/lib/hashira/coupling/cycle_findings.rb +31 -0
- data/lib/hashira/coupling/cycle_search.rb +37 -0
- data/lib/hashira/coupling/cycles.rb +14 -0
- data/lib/hashira/coupling/definition.rb +22 -0
- data/lib/hashira/coupling/definitions.rb +31 -0
- data/lib/hashira/coupling/edge.rb +10 -0
- data/lib/hashira/coupling/edge_map.rb +33 -0
- data/lib/hashira/coupling/folder_placement.rb +17 -0
- data/lib/hashira/coupling/folding.rb +56 -0
- data/lib/hashira/coupling/graph.rb +63 -0
- data/lib/hashira/coupling/metric.rb +15 -0
- data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
- data/lib/hashira/coupling/namespace_placement.rb +28 -0
- data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +11 -11
- data/lib/hashira/coupling/naming.rb +14 -0
- data/lib/hashira/coupling/no_folding.rb +13 -0
- data/lib/hashira/coupling/placement.rb +19 -0
- data/lib/hashira/coupling/references.rb +55 -0
- data/lib/hashira/coupling/report.rb +28 -0
- data/lib/hashira/coupling/roll_call.rb +45 -0
- data/lib/hashira/coupling/roll_call_findings.rb +30 -0
- data/lib/hashira/coupling/roster.rb +25 -0
- data/lib/hashira/coupling/rule.rb +18 -0
- data/lib/hashira/coupling/scope.rb +28 -0
- data/lib/hashira/coupling/sdp_check.rb +14 -0
- data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
- data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
- data/lib/hashira/coupling/words.rb +29 -0
- data/lib/hashira/diagram/dot.rb +8 -11
- data/lib/hashira/diagram/mermaid.rb +11 -16
- data/lib/hashira/diagram/source.rb +15 -0
- data/lib/hashira/duplication/clones.rb +17 -0
- data/lib/hashira/duplication/cluster.rb +14 -13
- data/lib/hashira/duplication/clusters.rb +46 -0
- data/lib/hashira/duplication/delta.rb +21 -30
- data/lib/hashira/duplication/duplication_finding.rb +17 -23
- data/lib/hashira/duplication/fragment.rb +19 -23
- data/lib/hashira/duplication/grouping.rb +12 -16
- data/lib/hashira/duplication/harvest.rb +27 -0
- data/lib/hashira/duplication/index.rb +20 -24
- data/lib/hashira/duplication/maximal.rb +11 -15
- data/lib/hashira/duplication/near_miss.rb +19 -22
- data/lib/hashira/duplication/sequence.rb +20 -22
- data/lib/hashira/duplication/similarity.rb +31 -34
- data/lib/hashira/duplication/union_find.rb +10 -14
- data/lib/hashira/duplication/variance.rb +35 -43
- data/lib/hashira/error.rb +4 -2
- data/lib/hashira/hotspots/file_cost.rb +8 -7
- data/lib/hashira/hotspots/rollup.rb +19 -21
- data/lib/hashira/pipeline.rb +41 -31
- data/lib/hashira/project.rb +50 -49
- data/lib/hashira/report/complexity_table.rb +27 -31
- data/lib/hashira/report/dependency_map.rb +17 -22
- data/lib/hashira/report/finding_lines.rb +15 -19
- data/lib/hashira/report/graph_payload.rb +14 -20
- data/lib/hashira/report/hotspot_table.rb +32 -37
- data/lib/hashira/report/json.rb +32 -32
- data/lib/hashira/report/metrics_table.rb +46 -33
- data/lib/hashira/report/phrases.rb +98 -0
- data/lib/hashira/report/smell_phrases.rb +68 -0
- data/lib/hashira/report/text.rb +59 -52
- data/lib/hashira/smells/census.rb +32 -0
- data/lib/hashira/smells/check.rb +30 -0
- data/lib/hashira/smells/conditions.rb +44 -0
- data/lib/hashira/smells/contexts.rb +64 -0
- data/lib/hashira/smells/control_parameter.rb +22 -0
- data/lib/hashira/smells/data_clump.rb +30 -0
- data/lib/hashira/smells/duplicate_method_call.rb +54 -0
- data/lib/hashira/smells/feature_envy.rb +17 -0
- data/lib/hashira/smells/instance_variable_assumption.rb +44 -0
- data/lib/hashira/smells/manual_dispatch.rb +16 -0
- data/lib/hashira/smells/module_initialize.rb +9 -0
- data/lib/hashira/smells/nil_check.rb +32 -0
- data/lib/hashira/smells/param_check.rb +51 -0
- data/lib/hashira/smells/refs.rb +53 -0
- data/lib/hashira/smells/repeated_conditional.rb +29 -0
- data/lib/hashira/smells/report.rb +31 -0
- data/lib/hashira/smells/scope.rb +29 -0
- data/lib/hashira/smells/too_many_instance_variables.rb +24 -0
- data/lib/hashira/smells/utility_function.rb +13 -0
- data/lib/hashira/smells/visibility.rb +64 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +131 -60
- metadata +62 -20
- data/lib/hashira/analysis/census.rb +0 -39
- data/lib/hashira/analysis/constant_registry.rb +0 -40
- data/lib/hashira/analysis/cycle_findings.rb +0 -33
- data/lib/hashira/analysis/cycle_search.rb +0 -44
- data/lib/hashira/analysis/definitions.rb +0 -27
- data/lib/hashira/analysis/edge.rb +0 -9
- data/lib/hashira/analysis/edge_map.rb +0 -36
- data/lib/hashira/analysis/graph.rb +0 -56
- data/lib/hashira/analysis/metric.rb +0 -14
- data/lib/hashira/analysis/references.rb +0 -39
- data/lib/hashira/analysis/rule.rb +0 -22
- data/lib/hashira/analysis/sdp_check.rb +0 -18
- data/lib/hashira/analysis/sdp_violation_findings.rb +0 -27
- data/lib/hashira/complexity/analyzer.rb +0 -45
- data/lib/hashira/diagram/renderer.rb +0 -20
- data/lib/hashira/duplication/analyzer.rb +0 -21
- data/lib/hashira/duplication/clusterer.rb +0 -50
- data/lib/hashira/duplication/extractor.rb +0 -31
|
@@ -1,37 +1,50 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
3
|
+
class Hashira::Report::MetricsTable
|
|
4
|
+
def initialize(graph, io: $stdout)
|
|
5
|
+
@graph = graph
|
|
6
|
+
@io = io
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
LIMIT = 25
|
|
10
|
+
|
|
11
|
+
def print
|
|
12
|
+
heading
|
|
13
|
+
kept.each { |package, metric| @io.puts(row(package, metric)) }
|
|
14
|
+
note unless spared.empty?
|
|
15
|
+
legend
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def ranked = @graph.metrics.sort_by { |_package, metric| metric.instability }
|
|
21
|
+
|
|
22
|
+
def split = @split ||= (ranked.size > LIMIT ? ranked.partition { |_package, metric| !leaf?(metric) } : [ranked, []])
|
|
23
|
+
|
|
24
|
+
def kept = split.first
|
|
25
|
+
|
|
26
|
+
def spared = split.last
|
|
27
|
+
|
|
28
|
+
def leaf?(metric) = metric.types <= 1 && metric.efferent.zero? && metric.afferent <= 1
|
|
29
|
+
|
|
30
|
+
def note
|
|
31
|
+
@io.puts(" + #{spared.size} single-type leaf packages (TC ≤ 1, Ca ≤ 1, Ce = 0) — hidden for brevity")
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def heading
|
|
35
|
+
@io.puts(format("%-12s %3s %3s %3s %5s %-3s", *%w[package TC Ca Ce I Cyc]))
|
|
36
|
+
@io.puts("-" * 40)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def row(package, metric)
|
|
40
|
+
format(
|
|
41
|
+
"%-12s %3d %3d %3d %5.2f %-3s", package, *metric.to_h.values,
|
|
42
|
+
(@graph.cycles.through?(package) ? "YES" : "-")
|
|
43
|
+
)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
def legend
|
|
47
|
+
@io.puts("\nLegend: TC total types, Ca afferent (incoming), Ce efferent (outgoing),")
|
|
48
|
+
@io.puts(" I=Ce/(Ce+Ca) instability (0=maximally stable, 1=maximally unstable)\n\n")
|
|
36
49
|
end
|
|
37
50
|
end
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira::Report::Phrases
|
|
4
|
+
COMPLEXITY_ADVICE = {
|
|
5
|
+
"if" => "flatten the branching — guard clauses, early returns, or polymorphism.",
|
|
6
|
+
"elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch.",
|
|
7
|
+
"else" => "flatten the branching — guard clauses, early returns, or polymorphism.",
|
|
8
|
+
"case" => "a case this size often wants polymorphism or a dispatch table.",
|
|
9
|
+
"boolean" => "name the compound condition in a predicate method.",
|
|
10
|
+
"rescue" => "narrow the rescue, or lift error handling to the caller.",
|
|
11
|
+
"while" => "extract the loop body into its own method.",
|
|
12
|
+
"until" => "extract the loop body into its own method.",
|
|
13
|
+
"for" => "extract the loop body into its own method.",
|
|
14
|
+
"unless" => "invert to a guard clause or a named predicate.",
|
|
15
|
+
"ternary" => "extract the nested ternary into a named method."
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
DUPLICATION_ADVICE = {
|
|
19
|
+
identical: "byte-for-byte identical — extract a shared method and call it from each site.",
|
|
20
|
+
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.",
|
|
22
|
+
constant: "differs only in a constant — extract a method and parameterize it.",
|
|
23
|
+
structure: "the control flow differs — extract the common core, but verify by hand (lower confidence).",
|
|
24
|
+
mixed: "extract the shared shape and pass what differs as parameters."
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
module_function
|
|
28
|
+
|
|
29
|
+
def message(finding) = public_send("on_#{finding.kind}", finding)
|
|
30
|
+
|
|
31
|
+
def on_cycle(finding)
|
|
32
|
+
detail = finding.detail
|
|
33
|
+
from, to = detail[:weak]
|
|
34
|
+
weight = detail[:weight]
|
|
35
|
+
"#{finding.package} can reach itself: #{finding.cycle.join(" -> ")} — any change may ripple back " \
|
|
36
|
+
"around. The lightest edge on this cycle is #{from} -> #{to} (#{weight} ref#{"s" unless weight == 1})."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_sdp_violation(finding)
|
|
40
|
+
detail = finding.detail
|
|
41
|
+
from, to = detail.values_at(:from, :to)
|
|
42
|
+
"#{from} (I=#{score(detail[:from_instability])}) depends on the LESS stable #{to} " \
|
|
43
|
+
"(I=#{score(detail[:to_instability])}) — churn in #{to} will force churn in #{from}. " \
|
|
44
|
+
"Invert the edge or extract the stable part of #{to} that #{from} needs."
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def on_mixed_audience(finding)
|
|
48
|
+
package = finding.package
|
|
49
|
+
parts = finding.detail[:parts]
|
|
50
|
+
"#{package} splits #{parts.size} ways: #{parts.map { clause(it) }.join("; ")} — " \
|
|
51
|
+
"parts with separate client bases are separate packages in disguise. " \
|
|
52
|
+
"Split #{package} along that seam#{addendum(parts)}."
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def on_wide_edge(finding)
|
|
56
|
+
detail = finding.detail
|
|
57
|
+
from, to = detail.values_at(:from, :to)
|
|
58
|
+
names = detail[:constants]
|
|
59
|
+
"#{from} -> #{to} is #{names.size} constants wide (#{names.join(", ")}) — " \
|
|
60
|
+
"every one is a reason for #{from} to change. Front #{to} with one facade."
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
def on_roll_call(finding)
|
|
64
|
+
detail = finding.detail
|
|
65
|
+
"the words #{detail[:words].join(", ")} are listed together in #{detail[:files].join(", ")} — " \
|
|
66
|
+
"#{detail[:packages].size} packages keep one roll-call in sync by hand. " \
|
|
67
|
+
"Make the list data with a single owner."
|
|
68
|
+
end
|
|
69
|
+
|
|
70
|
+
def on_complexity(finding)
|
|
71
|
+
detail = finding.detail
|
|
72
|
+
"#{finding.package} — cognitive #{detail[:cognitive]}, #{detail[:calls]} calls " \
|
|
73
|
+
"(#{detail[:site]}). #{COMPLEXITY_ADVICE.fetch(detail[:dominant])}"
|
|
74
|
+
end
|
|
75
|
+
|
|
76
|
+
def on_duplication(finding)
|
|
77
|
+
detail = finding.detail
|
|
78
|
+
"#{detail[:size]} similar fragments (mass #{detail[:mass]}) — " \
|
|
79
|
+
"#{DUPLICATION_ADVICE.fetch(detail[:kind])}#{footnote(detail)}"
|
|
80
|
+
end
|
|
81
|
+
|
|
82
|
+
def score(value) = format("%.2f", value)
|
|
83
|
+
|
|
84
|
+
def clause(part) = "#{part[:users].join(", ")} #{verb(part)} #{part[:constants].join(", ")}"
|
|
85
|
+
|
|
86
|
+
def verb(part)
|
|
87
|
+
return "share" if part[:shared]
|
|
88
|
+
part[:users].size == 1 ? "alone uses" : "use"
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
def addendum(parts)
|
|
92
|
+
parts.any? { it[:shared] } ? ", keeping the shared constants as the base layer the rest builds on" : ""
|
|
93
|
+
end
|
|
94
|
+
|
|
95
|
+
def footnote(detail)
|
|
96
|
+
detail[:hot] ? " Both sites change often — fix one, miss the other." : ""
|
|
97
|
+
end
|
|
98
|
+
end
|
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira::Report::Phrases
|
|
4
|
+
module_function
|
|
5
|
+
|
|
6
|
+
def on_control_parameter(finding)
|
|
7
|
+
detail = finding.detail
|
|
8
|
+
"#{finding.package} is steered by #{quoted(detail[:names])} (#{detail[:site]}). " \
|
|
9
|
+
"Split the method, or pass a strategy instead of a flag."
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def on_data_clump(finding)
|
|
13
|
+
"#{finding.package} passes the same parameters between methods (#{finding.detail[:site]}). " \
|
|
14
|
+
"Introduce a parameter object."
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def on_duplicate_method_call(finding)
|
|
18
|
+
"#{finding.package} repeats identical calls (#{finding.detail[:site]}). " \
|
|
19
|
+
"Name the result in a local variable."
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def on_feature_envy(finding)
|
|
23
|
+
detail = finding.detail
|
|
24
|
+
names = detail[:names]
|
|
25
|
+
"#{finding.package} refers to #{quoted(names)} more than to self (#{detail[:site]}). " \
|
|
26
|
+
"The behavior may belong on #{names.first}."
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def on_instance_variable_assumption(finding)
|
|
30
|
+
"#{finding.package} reads instance variables never set in initialize (#{finding.detail[:site]}). " \
|
|
31
|
+
"Assign them in initialize, or pass the data explicitly."
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def on_manual_dispatch(finding)
|
|
35
|
+
"#{finding.package} dispatches manually via respond_to? (#{finding.detail[:site]}). " \
|
|
36
|
+
"Trust the duck type, or split the callers into two adapters."
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def on_module_initialize(finding)
|
|
40
|
+
"#{finding.package} defines initialize in a module (#{finding.detail[:site]}). " \
|
|
41
|
+
"Move construction into the including class."
|
|
42
|
+
end
|
|
43
|
+
|
|
44
|
+
def on_nil_check(finding)
|
|
45
|
+
"#{finding.package} checks for nil (#{finding.detail[:site]}). " \
|
|
46
|
+
"Prefer a default, a null object, or polymorphism."
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
def on_repeated_conditional(finding)
|
|
50
|
+
tally(finding, "branches on the same test %d times", "Replace the scattered checks with polymorphism.")
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
def on_too_many_instance_variables(finding)
|
|
54
|
+
tally(finding, "holds %d instance variables", "Split the class, or gather related fields into value objects.")
|
|
55
|
+
end
|
|
56
|
+
|
|
57
|
+
def on_utility_function(finding)
|
|
58
|
+
"#{finding.package} touches no instance state (#{finding.detail[:site]}). " \
|
|
59
|
+
"Move it onto the object it serves, or make it a module function."
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def tally(finding, event, advice)
|
|
63
|
+
detail = finding.detail
|
|
64
|
+
"#{finding.package} #{format(event, detail[:count])} (#{detail[:site]}). #{advice}"
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
def quoted(names) = names.map { "'#{it}'" }.join(", ")
|
|
68
|
+
end
|
data/lib/hashira/report/text.rb
CHANGED
|
@@ -1,68 +1,75 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@io = io
|
|
9
|
-
end
|
|
3
|
+
class Hashira::Report::Text
|
|
4
|
+
def initialize(view, io: $stdout)
|
|
5
|
+
@view = view
|
|
6
|
+
@io = io
|
|
7
|
+
end
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
9
|
+
def print
|
|
10
|
+
coupling if @view.graph
|
|
11
|
+
complexity if @view.complexity
|
|
12
|
+
hotspots if @view.hotspots
|
|
13
|
+
findings
|
|
14
|
+
0
|
|
15
|
+
end
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
private
|
|
20
18
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
19
|
+
def coupling
|
|
20
|
+
graph = @view.graph
|
|
21
|
+
header(graph)
|
|
22
|
+
Hashira::Report::MetricsTable.new(graph, io: @io).print
|
|
23
|
+
Hashira::Report::DependencyMap.new(graph, io: @io).print
|
|
24
|
+
folded(graph.folds)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def folded(folds)
|
|
28
|
+
return if folds.empty?
|
|
29
|
+
@io.puts("\nFolded (single-type classes joined to their base or domain):")
|
|
30
|
+
folds.each { @io.puts(" #{it[:from]} -> #{it[:to]} (#{it[:via]})") }
|
|
31
|
+
end
|
|
27
32
|
|
|
28
|
-
|
|
33
|
+
def complexity = Hashira::Report::ComplexityTable.new(@view.complexity, io: @io).print
|
|
29
34
|
|
|
30
|
-
|
|
35
|
+
def hotspots = Hashira::Report::HotspotTable.new(@view.hotspots, io: @io).print
|
|
31
36
|
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
single_package_note if packages == 1
|
|
38
|
-
end
|
|
37
|
+
def header(graph)
|
|
38
|
+
packages = graph.packages.size
|
|
39
|
+
@io.puts(banner(packages))
|
|
40
|
+
caveat if packages == 1
|
|
41
|
+
end
|
|
39
42
|
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
end
|
|
43
|
+
def banner(packages)
|
|
44
|
+
"Package (layer) metrics for #{@view.project.label} (#{packages} packages, #{total} files)\n\n"
|
|
45
|
+
end
|
|
44
46
|
|
|
45
|
-
|
|
46
|
-
all = @view.findings.all
|
|
47
|
-
@io.puts "\nFindings (#{all.size}):"
|
|
48
|
-
print_findings(all)
|
|
49
|
-
accepted_section
|
|
50
|
-
@io.puts "\n Full evidence + machine format: hashira --json" unless all.empty?
|
|
51
|
-
end
|
|
47
|
+
def total = @view.project.files.size
|
|
52
48
|
|
|
53
|
-
|
|
54
|
-
|
|
49
|
+
def caveat
|
|
50
|
+
@io.puts(
|
|
51
|
+
"Only one package found — there are no boundaries to analyze. " \
|
|
52
|
+
"Pass subdirectories to set them (e.g. hashira lib/gem/*/).\n\n"
|
|
53
|
+
)
|
|
54
|
+
end
|
|
55
55
|
|
|
56
|
-
|
|
57
|
-
|
|
56
|
+
def findings
|
|
57
|
+
all = @view.findings.all
|
|
58
|
+
@io.puts("\nFindings (#{all.size}):")
|
|
59
|
+
list(all)
|
|
60
|
+
accepted
|
|
61
|
+
@io.puts("\n Full evidence + machine format: hashira --json") unless all.empty?
|
|
62
|
+
end
|
|
58
63
|
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
64
|
+
def list(all)
|
|
65
|
+
return @io.puts(" none ✓ — structure is healthy") if all.empty?
|
|
66
|
+
all.each { Hashira::Report::FindingLines.new(it, indent: " ", io: @io).emit }
|
|
67
|
+
end
|
|
62
68
|
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
69
|
+
def accepted
|
|
70
|
+
accepted = @view.findings.accepted
|
|
71
|
+
return if accepted.empty?
|
|
72
|
+
@io.puts("\nAccepted (#{accepted.size}):")
|
|
73
|
+
accepted.each { |finding, reason| @io.puts(" ~ #{finding.kind}/#{finding.package} — #{reason}") }
|
|
67
74
|
end
|
|
68
75
|
end
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::Census
|
|
6
|
+
def initialize(project, trees)
|
|
7
|
+
@project = project
|
|
8
|
+
@trees = trees
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def types = @trees.flat_map { |path, tree| harvest(@project.relative(path), tree) }
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def harvest(file, tree)
|
|
16
|
+
found = []
|
|
17
|
+
Hashira::Analysis::TypeWalk.each(tree) { |node, full| found << context(file, node, full.join("::")) }
|
|
18
|
+
found
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
def context(file, node, name)
|
|
22
|
+
Hashira::Smells::TypeContext.new(name:, node:, kind: kind(node), file:, defs: defs(name, node, file))
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def kind(node) = node.is_a?(Prism::ModuleNode) ? :module : :class
|
|
26
|
+
|
|
27
|
+
def defs(name, node, file)
|
|
28
|
+
Hashira::Smells::Visibility.new(node).entries.map do |def_node, section|
|
|
29
|
+
Hashira::Smells::MethodContext.new(owner: name, node: def_node, file:, section:)
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Smells::Check
|
|
4
|
+
def initialize(subject)
|
|
5
|
+
@subject = subject
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
def finding
|
|
9
|
+
return unless smelly?
|
|
10
|
+
Hashira::Analysis::Finding.new(kind:, package: label, detail:, evidence:)
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
attr_reader :subject
|
|
16
|
+
|
|
17
|
+
def kind = self.class.name.split("::").last.gsub(/(?<=[a-z])(?=[A-Z])/, "_").downcase
|
|
18
|
+
|
|
19
|
+
def label = subject.subject
|
|
20
|
+
|
|
21
|
+
def site = subject.site
|
|
22
|
+
|
|
23
|
+
def detail = { site: }
|
|
24
|
+
|
|
25
|
+
def spots(nodes) = "#{subject.file}:#{nodes.map { it.location.start_line }.uniq.join(", ")}"
|
|
26
|
+
|
|
27
|
+
def evidence = []
|
|
28
|
+
|
|
29
|
+
def tally(name, lines) = "#{name} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
|
|
30
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira::Smells::Conditions
|
|
6
|
+
TESTED = [Prism::IfNode, Prism::UnlessNode, Prism::CaseNode, Prism::AndNode, Prism::OrNode].freeze
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def tested?(node) = TESTED.include?(node.class)
|
|
11
|
+
|
|
12
|
+
def fence?(node) = Hashira::Smells::Scope::FENCES.include?(node.class)
|
|
13
|
+
|
|
14
|
+
def couple?(node) = node.is_a?(Prism::AndNode) || node.is_a?(Prism::OrNode)
|
|
15
|
+
|
|
16
|
+
def condition(node)
|
|
17
|
+
case node
|
|
18
|
+
when Prism::IfNode, Prism::UnlessNode, Prism::CaseNode then node.predicate
|
|
19
|
+
when Prism::AndNode, Prism::OrNode then node.left
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def branches(node)
|
|
24
|
+
return fork(node) if node.is_a?(Prism::IfNode) || node.is_a?(Prism::UnlessNode)
|
|
25
|
+
return node.conditions + [node.consequent] if node.is_a?(Prism::CaseNode)
|
|
26
|
+
couple?(node) ? [node.right] : [node.body]
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def fork(node)
|
|
30
|
+
[node.statements, node.is_a?(Prism::IfNode) ? node.subsequent : node.else_clause]
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def nested(roots) = roots.compact.flat_map { seek(it) }
|
|
34
|
+
|
|
35
|
+
def seek(node)
|
|
36
|
+
return [node] if tested?(node)
|
|
37
|
+
fence?(node) ? [] : node.compact_child_nodes.flat_map { seek(it) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def plain(node)
|
|
41
|
+
return [] if tested?(node) || fence?(node)
|
|
42
|
+
[node] + node.compact_child_nodes.flat_map { plain(it) }
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module Smells
|
|
7
|
+
module Parameters
|
|
8
|
+
NAMELESS = [Prism::ForwardingParameterNode, Prism::ImplicitRestNode, Prism::NoKeywordsParameterNode].freeze
|
|
9
|
+
|
|
10
|
+
module_function
|
|
11
|
+
|
|
12
|
+
def names(def_node) = parts(def_node).flat_map { expand(it) }
|
|
13
|
+
|
|
14
|
+
def arguments(def_node) = parts(def_node).grep_v(Prism::BlockParameterNode).flat_map { expand(it) }
|
|
15
|
+
|
|
16
|
+
def parts(def_node)
|
|
17
|
+
node = def_node.parameters
|
|
18
|
+
return [] unless node
|
|
19
|
+
node.requireds + node.optionals + node.posts + node.keywords +
|
|
20
|
+
[node.rest, node.keyword_rest, node.block].compact
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def expand(part)
|
|
24
|
+
case part
|
|
25
|
+
when Prism::MultiTargetNode then pieces(part).flat_map { expand(it) }
|
|
26
|
+
when *NAMELESS then []
|
|
27
|
+
else [part.name].compact
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def pieces(part) = part.lefts + Array(part.rest&.expression) + part.rights
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
MethodContext =
|
|
35
|
+
Data.define(:owner, :node, :file, :section) do
|
|
36
|
+
def subject = "#{owner}#{singleton? ? "." : "#"}#{node.name}"
|
|
37
|
+
|
|
38
|
+
def line = node.location.start_line
|
|
39
|
+
|
|
40
|
+
def singleton? = node.receiver.is_a?(Prism::Node) || section == :singleton
|
|
41
|
+
|
|
42
|
+
def mixin? = section == :module_function
|
|
43
|
+
|
|
44
|
+
def public? = section == :public && !singleton?
|
|
45
|
+
|
|
46
|
+
def parameters = Parameters.names(node)
|
|
47
|
+
|
|
48
|
+
def arguments = Parameters.arguments(node)
|
|
49
|
+
|
|
50
|
+
def site = "#{file}:#{line}"
|
|
51
|
+
end
|
|
52
|
+
|
|
53
|
+
TypeContext =
|
|
54
|
+
Data.define(:name, :node, :kind, :file, :defs) do
|
|
55
|
+
def line = node.location.start_line
|
|
56
|
+
|
|
57
|
+
def subject = name
|
|
58
|
+
|
|
59
|
+
def owned = defs.reject(&:singleton?)
|
|
60
|
+
|
|
61
|
+
def site = "#{file}:#{line}"
|
|
62
|
+
end
|
|
63
|
+
end
|
|
64
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Smells::ControlParameter < Hashira::Smells::Check
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def smelly? = culprits.any?
|
|
7
|
+
|
|
8
|
+
def culprits = @culprits ||= subject.parameters.filter_map { |name| culprit(name) }
|
|
9
|
+
|
|
10
|
+
def culprit(name)
|
|
11
|
+
lines = spots(name).uniq
|
|
12
|
+
[name, lines] unless lines.empty?
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def spots(name)
|
|
16
|
+
Hashira::Smells::ParamCheck.new(subject.node, name).matches.map { it.location.start_line }
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def detail = { site:, names: culprits.map { |name, _lines| name } }
|
|
20
|
+
|
|
21
|
+
def evidence = culprits.map { |name, lines| tally(name, lines) }
|
|
22
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Smells::DataClump < Hashira::Smells::Check
|
|
4
|
+
MAX_COPIES = 2
|
|
5
|
+
|
|
6
|
+
MIN_SIZE = 2
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def smelly? = clumps.any?
|
|
11
|
+
|
|
12
|
+
def candidates = @candidates ||= subject.owned.map { [it, it.arguments.sort] }
|
|
13
|
+
|
|
14
|
+
def clumps = @clumps ||= shared.map { |clump| [clump, holders(clump)] }
|
|
15
|
+
|
|
16
|
+
def shared = candidates.combination(MAX_COPIES + 1).filter_map { |group| clump(group) }.uniq
|
|
17
|
+
|
|
18
|
+
def clump(group)
|
|
19
|
+
names = group.map(&:last).inject(:&)
|
|
20
|
+
names if names.size >= MIN_SIZE
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def holders(clump) = candidates.filter_map { |method, names| method if (clump - names).empty? }
|
|
24
|
+
|
|
25
|
+
def evidence = clumps.map { |clump, holders| row(clump, holders) }
|
|
26
|
+
|
|
27
|
+
def row(clump, holders)
|
|
28
|
+
"(#{clump.join(", ")}) → #{holders.size} methods: #{holders.map { |holder| holder.node.name }.join(", ")}"
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Smells::DuplicateMethodCall < Hashira::Smells::Check
|
|
6
|
+
LIMIT = 1
|
|
7
|
+
|
|
8
|
+
private
|
|
9
|
+
|
|
10
|
+
def smelly? = repeats.any?
|
|
11
|
+
|
|
12
|
+
def calls = @calls ||= Hashira::Smells::Scope.inside(subject.node).grep(Prism::CallNode)
|
|
13
|
+
|
|
14
|
+
def plain?(node)
|
|
15
|
+
!node.receiver && !node.arguments && !node.block.is_a?(Prism::BlockArgumentNode)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def repeats
|
|
19
|
+
@repeats ||= usual.reject { |_handle, nodes| whole.value?(nodes) }.merge(whole)
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def usual
|
|
23
|
+
calls.reject { plain?(it) || it.name == :new }
|
|
24
|
+
.group_by { handle(it) }.select { |_handle, group| group.size > LIMIT }
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def whole
|
|
28
|
+
@whole ||=
|
|
29
|
+
calls.select { it.block.is_a?(Prism::BlockNode) }
|
|
30
|
+
.group_by { it.slice.gsub(/\s+/, " ") }.select { |_handle, group| group.size > LIMIT }
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def handle(node) = "#{title(node)}#{signature(node)}"
|
|
34
|
+
|
|
35
|
+
def title(node)
|
|
36
|
+
[node.receiver&.slice, node.name].compact.join(node.safe_navigation? ? "&." : ".")
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def signature(node)
|
|
40
|
+
wrap(([node.arguments&.slice] + pass(node.block)).compact)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
def pass(block) = block.is_a?(Prism::BlockArgumentNode) ? [block.slice] : []
|
|
44
|
+
|
|
45
|
+
def wrap(parts) = parts.empty? ? "" : "(#{parts.join(", ")})"
|
|
46
|
+
|
|
47
|
+
def evidence
|
|
48
|
+
repeats.map { |handle, nodes| "#{handle} × #{nodes.size} (#{stamp(lines(nodes))})" }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def lines(nodes) = nodes.map { it.location.start_line }.uniq
|
|
52
|
+
|
|
53
|
+
def stamp(lines) = "line#{"s" if lines.size > 1} #{lines.join(", ")}"
|
|
54
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Smells::FeatureEnvy < Hashira::Smells::Check
|
|
4
|
+
private
|
|
5
|
+
|
|
6
|
+
def smelly?
|
|
7
|
+
!subject.singleton? && !subject.mixin? && refs.ego.positive? && envied.any?
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def refs = @refs ||= Hashira::Smells::Refs.new(subject.node)
|
|
11
|
+
|
|
12
|
+
def envied = @envied ||= refs.envious
|
|
13
|
+
|
|
14
|
+
def detail = { site:, names: envied }
|
|
15
|
+
|
|
16
|
+
def evidence = envied.map { |name| tally(name, refs.lines(name).uniq) }
|
|
17
|
+
end
|