hashira 0.1.0 → 0.3.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 +97 -0
- data/README.md +334 -32
- data/lib/hashira/analysis/census.rb +12 -14
- data/lib/hashira/analysis/constant_registry.rb +40 -0
- data/lib/hashira/analysis/cycle_findings.rb +1 -1
- data/lib/hashira/analysis/cycle_search.rb +1 -1
- data/lib/hashira/analysis/definitions.rb +1 -1
- data/lib/hashira/analysis/finding.rb +6 -2
- data/lib/hashira/analysis/graph.rb +3 -3
- data/lib/hashira/analysis/namespace_prefix.rb +32 -0
- data/lib/hashira/analysis/node_walk.rb +7 -1
- data/lib/hashira/analysis/references.rb +2 -2
- data/lib/hashira/analysis/sdp_check.rb +1 -1
- data/lib/hashira/analysis/type_walk.rb +1 -1
- data/lib/hashira/churn.rb +22 -0
- data/lib/hashira/ci/accepted.rb +11 -7
- data/lib/hashira/ci/baseline.rb +40 -0
- data/lib/hashira/ci/diff.rb +15 -0
- data/lib/hashira/ci/edge_diff_report.rb +4 -27
- data/lib/hashira/ci/finding_diff_report.rb +26 -0
- data/lib/hashira/ci/gate.rb +7 -5
- data/lib/hashira/ci/improvement.rb +19 -0
- data/lib/hashira/ci/ratchet.rb +16 -20
- data/lib/hashira/ci/ratchet_report.rb +42 -0
- data/lib/hashira/cli/command_line.rb +12 -6
- data/lib/hashira/cli/fail_on.rb +4 -2
- data/lib/hashira/cli/options.rb +1 -1
- data/lib/hashira/cli/run.rb +9 -3
- data/lib/hashira/cli/skip.rb +27 -0
- data/lib/hashira/cli/usage.rb +10 -5
- data/lib/hashira/cli.rb +1 -1
- data/lib/hashira/complexity/analyzer.rb +45 -0
- data/lib/hashira/complexity/boolean_run.rb +22 -0
- data/lib/hashira/complexity/cognitive_score.rb +72 -0
- data/lib/hashira/complexity/if_chain.rb +45 -0
- data/lib/hashira/complexity/method_finding.rb +52 -0
- data/lib/hashira/complexity/method_score.rb +17 -0
- data/lib/hashira/complexity/rescue_scan.rb +28 -0
- data/lib/hashira/complexity/rollup.rb +22 -0
- data/lib/hashira/duplication/analyzer.rb +21 -0
- data/lib/hashira/duplication/cluster.rb +26 -0
- data/lib/hashira/duplication/clusterer.rb +50 -0
- data/lib/hashira/duplication/delta.rb +39 -0
- data/lib/hashira/duplication/duplication_finding.rb +30 -0
- data/lib/hashira/duplication/extractor.rb +31 -0
- data/lib/hashira/duplication/fragment.rb +42 -0
- data/lib/hashira/duplication/grouping.rb +24 -0
- data/lib/hashira/duplication/index.rb +35 -0
- data/lib/hashira/duplication/maximal.rb +21 -0
- data/lib/hashira/duplication/near_miss.rb +33 -0
- data/lib/hashira/duplication/sequence.rb +36 -0
- data/lib/hashira/duplication/similarity.rb +50 -0
- data/lib/hashira/duplication/union_find.rb +21 -0
- data/lib/hashira/duplication/variance.rb +57 -0
- data/lib/hashira/hotspots/file_cost.rb +19 -0
- data/lib/hashira/hotspots/rollup.rb +33 -0
- data/lib/hashira/pipeline.rb +28 -6
- data/lib/hashira/project.rb +32 -9
- data/lib/hashira/report/complexity_table.rb +40 -0
- data/lib/hashira/report/dependency_map.rb +8 -5
- data/lib/hashira/report/finding_lines.rb +1 -1
- data/lib/hashira/report/graph_payload.rb +29 -0
- data/lib/hashira/report/hotspot_table.rb +41 -0
- data/lib/hashira/report/json.rb +24 -17
- data/lib/hashira/report/metrics_table.rb +1 -2
- data/lib/hashira/report/text.rb +24 -14
- data/lib/hashira/report/view.rb +7 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +38 -1
- metadata +46 -7
- data/lib/hashira/analysis/root_namespace.rb +0 -14
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Complexity
|
|
5
|
+
class Analyzer
|
|
6
|
+
THRESHOLD = 10
|
|
7
|
+
|
|
8
|
+
def initialize(project, trees)
|
|
9
|
+
@project = project
|
|
10
|
+
@scores = trees.flat_map { |path, tree| scores_for(path, tree) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def methods = @scores.sort_by { -it.cognitive }
|
|
14
|
+
|
|
15
|
+
def classes = Rollup.new(@scores).classes.sort_by { -it.cognitive }
|
|
16
|
+
|
|
17
|
+
def findings = flagged.map { MethodFinding.new(it).to_finding }
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def flagged = methods.select { it.cognitive >= THRESHOLD }
|
|
22
|
+
|
|
23
|
+
def scores_for(path, tree)
|
|
24
|
+
rel = @project.relative(path)
|
|
25
|
+
methods_in(tree).map { |full, node| build_score(rel, full, node) }
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def methods_in(tree)
|
|
29
|
+
found = []
|
|
30
|
+
Analysis::TypeWalk.each_definition(tree) do |type_node, full|
|
|
31
|
+
Analysis::Syntax.direct_definitions(type_node).each { found << [full, it] }
|
|
32
|
+
end
|
|
33
|
+
found
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def build_score(rel, full, node)
|
|
37
|
+
score = CognitiveScore.new(node)
|
|
38
|
+
MethodScore.new(subject: subject(full, node), file: rel, line: node.location.start_line,
|
|
39
|
+
cognitive: score.total, calls: score.calls, increments: score.increments)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def subject(full, node) = "#{full.join("::")}#{node.receiver ? "." : "#"}#{node.name}"
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Complexity
|
|
5
|
+
class BooleanRun
|
|
6
|
+
def initialize(scorer)
|
|
7
|
+
@scorer = scorer
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def apply(node, nesting)
|
|
11
|
+
@scorer.add(node, 1, "boolean")
|
|
12
|
+
operands(node).each { @scorer.visit(it, nesting) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def operands(node)
|
|
18
|
+
node.compact_child_nodes.flat_map { it.instance_of?(node.class) ? operands(it) : [it] }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module Complexity
|
|
7
|
+
class CognitiveScore
|
|
8
|
+
HANDLERS = {
|
|
9
|
+
Prism::IfNode => :on_if,
|
|
10
|
+
Prism::UnlessNode => :on_nester,
|
|
11
|
+
Prism::WhileNode => :on_nester,
|
|
12
|
+
Prism::UntilNode => :on_nester,
|
|
13
|
+
Prism::ForNode => :on_nester,
|
|
14
|
+
Prism::CaseNode => :on_nester,
|
|
15
|
+
Prism::CaseMatchNode => :on_nester,
|
|
16
|
+
Prism::BeginNode => :on_begin,
|
|
17
|
+
Prism::AndNode => :on_boolean,
|
|
18
|
+
Prism::OrNode => :on_boolean,
|
|
19
|
+
Prism::BlockNode => :on_block,
|
|
20
|
+
Prism::CallNode => :on_call
|
|
21
|
+
}.freeze
|
|
22
|
+
|
|
23
|
+
LABELS = {
|
|
24
|
+
Prism::UnlessNode => "unless", Prism::WhileNode => "while",
|
|
25
|
+
Prism::UntilNode => "until", Prism::ForNode => "for",
|
|
26
|
+
Prism::CaseNode => "case", Prism::CaseMatchNode => "case"
|
|
27
|
+
}.freeze
|
|
28
|
+
|
|
29
|
+
def initialize(def_node)
|
|
30
|
+
@increments = []
|
|
31
|
+
@calls = 0
|
|
32
|
+
visit(def_node.body, 0)
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
attr_reader :increments, :calls
|
|
36
|
+
|
|
37
|
+
def total = @increments.sum(&:cost)
|
|
38
|
+
|
|
39
|
+
def visit(node, nesting)
|
|
40
|
+
return unless node
|
|
41
|
+
|
|
42
|
+
send(HANDLERS.fetch(node.class, :descend), node, nesting)
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def add(node, cost, label)
|
|
46
|
+
@increments << Increment.new(line: node.location.start_line, cost:, label:)
|
|
47
|
+
end
|
|
48
|
+
|
|
49
|
+
private
|
|
50
|
+
|
|
51
|
+
def descend(node, nesting) = node.compact_child_nodes.each { visit(it, nesting) }
|
|
52
|
+
|
|
53
|
+
def on_call(node, nesting)
|
|
54
|
+
@calls += 1
|
|
55
|
+
descend(node, nesting)
|
|
56
|
+
end
|
|
57
|
+
|
|
58
|
+
def on_block(node, nesting) = node.compact_child_nodes.each { visit(it, nesting + 1) }
|
|
59
|
+
|
|
60
|
+
def on_nester(node, nesting)
|
|
61
|
+
add(node, 1 + nesting, LABELS.fetch(node.class))
|
|
62
|
+
node.compact_child_nodes.each { visit(it, nesting + 1) }
|
|
63
|
+
end
|
|
64
|
+
|
|
65
|
+
def on_if(node, nesting) = IfChain.new(self).apply(node, nesting)
|
|
66
|
+
|
|
67
|
+
def on_begin(node, nesting) = RescueScan.new(self).apply(node, nesting)
|
|
68
|
+
|
|
69
|
+
def on_boolean(node, nesting) = BooleanRun.new(self).apply(node, nesting)
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module Complexity
|
|
7
|
+
class IfChain
|
|
8
|
+
def initialize(scorer)
|
|
9
|
+
@scorer = scorer
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def apply(node, nesting)
|
|
13
|
+
return ternary(node, nesting) unless node.if_keyword
|
|
14
|
+
|
|
15
|
+
branch(node, 1 + nesting, nesting, "if")
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
private
|
|
19
|
+
|
|
20
|
+
def branch(node, cost, nesting, label)
|
|
21
|
+
@scorer.add(node, cost, label)
|
|
22
|
+
@scorer.visit(node.predicate, nesting)
|
|
23
|
+
@scorer.visit(node.statements, nesting + 1)
|
|
24
|
+
tail(node.subsequent, nesting)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def tail(node, nesting)
|
|
28
|
+
case node
|
|
29
|
+
when Prism::IfNode then branch(node, 1, nesting, "elsif")
|
|
30
|
+
when Prism::ElseNode then otherwise(node, nesting)
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def otherwise(node, nesting)
|
|
35
|
+
@scorer.add(node, 1, "else")
|
|
36
|
+
@scorer.visit(node.statements, nesting + 1)
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
def ternary(node, nesting)
|
|
40
|
+
@scorer.add(node, 1, "ternary")
|
|
41
|
+
node.compact_child_nodes.each { @scorer.visit(it, nesting) }
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Complexity
|
|
5
|
+
class MethodFinding
|
|
6
|
+
ADVICE = {
|
|
7
|
+
"if" => "flatten the branching — guard clauses, early returns, or polymorphism.",
|
|
8
|
+
"elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch.",
|
|
9
|
+
"else" => "flatten the branching — guard clauses, early returns, or polymorphism.",
|
|
10
|
+
"case" => "a case this size often wants polymorphism or a dispatch table.",
|
|
11
|
+
"boolean" => "name the compound condition in a predicate method.",
|
|
12
|
+
"rescue" => "narrow the rescue, or lift error handling to the caller.",
|
|
13
|
+
"while" => "extract the loop body into its own method.",
|
|
14
|
+
"until" => "extract the loop body into its own method.",
|
|
15
|
+
"for" => "extract the loop body into its own method.",
|
|
16
|
+
"unless" => "invert to a guard clause or a named predicate.",
|
|
17
|
+
"ternary" => "extract the nested ternary into a named method."
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
def initialize(score)
|
|
21
|
+
@score = score
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def to_finding
|
|
25
|
+
Analysis::Finding.new(kind: "complexity", package: @score.subject, cycle: nil,
|
|
26
|
+
message:, evidence:)
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
private
|
|
30
|
+
|
|
31
|
+
def message
|
|
32
|
+
"#{@score.subject} — cognitive #{@score.cognitive}, #{@score.calls} calls " \
|
|
33
|
+
"(#{@score.file}:#{@score.line}). #{advice}"
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def evidence
|
|
37
|
+
@score.increments.group_by(&:label).map { |label, incs| line_summary(label, incs) }
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
def line_summary(label, incs)
|
|
41
|
+
lines = incs.map(&:line).uniq
|
|
42
|
+
"#{label} +#{incs.sum(&:cost)} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def advice = ADVICE.fetch(dominant)
|
|
46
|
+
|
|
47
|
+
def dominant
|
|
48
|
+
@score.increments.group_by(&:label).transform_values { it.sum(&:cost) }.max_by(&:last).first
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Complexity
|
|
5
|
+
Increment = Data.define(:line, :cost, :label)
|
|
6
|
+
|
|
7
|
+
MethodScore = Data.define(:subject, :file, :line, :cognitive, :calls, :increments) do
|
|
8
|
+
def to_h = { subject:, file:, line:, cognitive:, calls: }
|
|
9
|
+
|
|
10
|
+
def cells = [subject, cognitive, calls, "#{file}:#{line}"]
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
ClassScore = Data.define(:name, :cognitive, :method_count, :peak) do
|
|
14
|
+
def cells = [name, cognitive, method_count, peak]
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Complexity
|
|
5
|
+
class RescueScan
|
|
6
|
+
def initialize(scorer)
|
|
7
|
+
@scorer = scorer
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def apply(node, nesting)
|
|
11
|
+
@scorer.visit(node.statements, nesting)
|
|
12
|
+
clauses(node.rescue_clause, nesting)
|
|
13
|
+
@scorer.visit(node.else_clause, nesting)
|
|
14
|
+
@scorer.visit(node.ensure_clause, nesting)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def clauses(node, nesting)
|
|
20
|
+
return unless node
|
|
21
|
+
|
|
22
|
+
@scorer.add(node, 1 + nesting, "rescue")
|
|
23
|
+
@scorer.visit(node.statements, nesting + 1)
|
|
24
|
+
clauses(node.subsequent, nesting)
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Complexity
|
|
5
|
+
class Rollup
|
|
6
|
+
def initialize(scores)
|
|
7
|
+
@scores = scores
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def classes = @scores.group_by { class_name(it.subject) }.map { |name, group| score(name, group) }
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def score(name, group)
|
|
15
|
+
ClassScore.new(name:, cognitive: group.sum(&:cognitive), method_count: group.size,
|
|
16
|
+
peak: group.map(&:cognitive).max)
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def class_name(subject) = subject.split(/[#.]/, 2).first
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class Analyzer
|
|
6
|
+
def initialize(project, trees, churn)
|
|
7
|
+
@project = project
|
|
8
|
+
@trees = trees
|
|
9
|
+
@churn = churn
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def clusters = @clusters ||= Clusterer.new(fragments).clusters.sort_by { -it.mass }
|
|
13
|
+
|
|
14
|
+
def findings = clusters.map { |cluster| DuplicationFinding.new(cluster, @churn).to_finding }
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def fragments = Extractor.new(@project, @trees).fragments
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
Cluster = Data.define(:sites) do
|
|
6
|
+
def canonical = sites.max_by { |site| [shape_count(site), site.mass] }
|
|
7
|
+
|
|
8
|
+
def shape_count(site) = sites.count { |other| other.types == site.types }
|
|
9
|
+
|
|
10
|
+
def others
|
|
11
|
+
chosen = canonical
|
|
12
|
+
sites.reject { |site| site.equal?(chosen) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def exact_sites = sites.select { it.types == canonical.types }
|
|
16
|
+
|
|
17
|
+
def mass = canonical.mass
|
|
18
|
+
|
|
19
|
+
def size = sites.size
|
|
20
|
+
|
|
21
|
+
def site_masses = sites.map { [it.file, mass] }
|
|
22
|
+
|
|
23
|
+
def shape_only? = others.all? { Variance.new(canonical, it).shape_only? }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class Clusterer
|
|
6
|
+
PREFILTER = 12
|
|
7
|
+
BASE_MASS = 16
|
|
8
|
+
NEAR_MASS = 40
|
|
9
|
+
PAIR = 2
|
|
10
|
+
PENALTY_PER_RECURRENCE = 2
|
|
11
|
+
|
|
12
|
+
def initialize(fragments)
|
|
13
|
+
@fragments = fragments.select { |fragment| fragment.mass >= PREFILTER }
|
|
14
|
+
@sets = UnionFind.new
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def clusters
|
|
18
|
+
@fragments.group_by(&:types).each_value { |group| chain(group) }
|
|
19
|
+
NearMiss.new(@fragments).pairs.each { |left, right| @sets.union(left, right) }
|
|
20
|
+
Maximal.new(sized).reduced
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def chain(group) = group.each_cons(2) { |left, right| @sets.union(left, right) }
|
|
26
|
+
|
|
27
|
+
def sized = built.filter_map { admitted(it) }
|
|
28
|
+
|
|
29
|
+
def admitted(cluster) = [cluster, exact_core(cluster)].compact.find { fits?(it) }
|
|
30
|
+
|
|
31
|
+
def fits?(cluster) = cluster.mass >= floor(cluster)
|
|
32
|
+
|
|
33
|
+
def exact_core(cluster) = Grouping.new(cluster.exact_sites).cluster
|
|
34
|
+
|
|
35
|
+
def built = @sets.clusters.filter_map { |group| Grouping.new(group).cluster }
|
|
36
|
+
|
|
37
|
+
def floor(cluster) = base(cluster) + idiom_penalty(cluster)
|
|
38
|
+
|
|
39
|
+
def base(cluster) = thin_evidence?(cluster) ? NEAR_MASS : BASE_MASS
|
|
40
|
+
|
|
41
|
+
def thin_evidence?(cluster) = !one_shape?(cluster) || cluster.shape_only?
|
|
42
|
+
|
|
43
|
+
def idiom_penalty(cluster) = recurrences(cluster) * PENALTY_PER_RECURRENCE
|
|
44
|
+
|
|
45
|
+
def recurrences(cluster) = [cluster.size - PAIR, 0].max
|
|
46
|
+
|
|
47
|
+
def one_shape?(cluster) = cluster.sites.map(&:types).uniq.size == 1
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class Delta
|
|
6
|
+
ADVICE = {
|
|
7
|
+
identical: "byte-for-byte identical — extract a shared method and call it from each site.",
|
|
8
|
+
literal: "differs only in literal values — extract a method, pass them as arguments.",
|
|
9
|
+
message: "differs only in the receiver or message — extract a method taking the receiver.",
|
|
10
|
+
constant: "differs only in a constant — extract a method and parameterize it.",
|
|
11
|
+
structure: "the control flow differs — extract the common core, but verify by hand (lower confidence).",
|
|
12
|
+
mixed: "extract the shared shape and pass what differs as parameters."
|
|
13
|
+
}.freeze
|
|
14
|
+
|
|
15
|
+
def initialize(cluster)
|
|
16
|
+
@cluster = cluster
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def summary = ADVICE.fetch(kind)
|
|
20
|
+
|
|
21
|
+
def kind
|
|
22
|
+
tags = kinds
|
|
23
|
+
return :identical if tags.empty?
|
|
24
|
+
return :structure if tags.include?(:structure)
|
|
25
|
+
|
|
26
|
+
tags.size == 1 ? tags.first : :mixed
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def to_h
|
|
30
|
+
{ mass: @cluster.mass, sites: @cluster.size, kind:,
|
|
31
|
+
locations: @cluster.sites.sort_by(&:sort_key).map(&:range) }
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
private
|
|
35
|
+
|
|
36
|
+
def kinds = @cluster.others.flat_map { |other| Variance.new(@cluster.canonical, other).kinds }.uniq
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class DuplicationFinding
|
|
6
|
+
def initialize(cluster, churn)
|
|
7
|
+
@cluster = cluster
|
|
8
|
+
@churn = churn
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_finding
|
|
12
|
+
site = @cluster.canonical
|
|
13
|
+
Analysis::Finding.new(kind: "duplication", package: site.location, digest: site.digest,
|
|
14
|
+
cycle: nil, message:, evidence:)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def message
|
|
20
|
+
"#{@cluster.size} similar fragments (mass #{@cluster.mass}) — #{Delta.new(@cluster).summary}#{note}"
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def evidence = @cluster.sites.sort_by(&:sort_key).map(&:range)
|
|
24
|
+
|
|
25
|
+
def note
|
|
26
|
+
@churn.hot?(@cluster.sites) ? " Both sites change often — fix one, miss the other." : ""
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module Duplication
|
|
7
|
+
class Extractor
|
|
8
|
+
WHOLE = [Prism::DefNode, Prism::WhenNode, Prism::RescueNode].freeze
|
|
9
|
+
|
|
10
|
+
def initialize(project, trees)
|
|
11
|
+
@project = project
|
|
12
|
+
@fragments = trees.flat_map { |path, tree| from_tree(@project.relative(path), tree) }
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :fragments
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def from_tree(rel, tree)
|
|
20
|
+
nodes = Analysis::NodeWalk.collect(tree)
|
|
21
|
+
windows(rel, nodes) + wholes(nodes).map { Fragment.new(rel, [it]) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def windows(rel, nodes) = runs(nodes).flat_map { Sequence.new(rel, it).fragments }
|
|
25
|
+
|
|
26
|
+
def runs(nodes) = nodes.filter_map { it.body if it.is_a?(Prism::StatementsNode) }
|
|
27
|
+
|
|
28
|
+
def wholes(nodes) = nodes.select { WHOLE.include?(it.class) }
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "digest"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module Duplication
|
|
7
|
+
class Fragment
|
|
8
|
+
DIGEST_LENGTH = 12
|
|
9
|
+
|
|
10
|
+
def initialize(file, roots)
|
|
11
|
+
@file = file
|
|
12
|
+
@roots = roots
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
attr_reader :file
|
|
16
|
+
|
|
17
|
+
def types = @types ||= nodes.map(&:type)
|
|
18
|
+
|
|
19
|
+
def digest = Digest::SHA256.hexdigest(shape).slice(0, DIGEST_LENGTH)
|
|
20
|
+
|
|
21
|
+
def shape = types.join(",")
|
|
22
|
+
|
|
23
|
+
def mass = types.size
|
|
24
|
+
|
|
25
|
+
def line = @roots.first.location.start_line
|
|
26
|
+
|
|
27
|
+
def finish = @roots.last.location.end_line
|
|
28
|
+
|
|
29
|
+
def location = "#{file}:#{line}"
|
|
30
|
+
|
|
31
|
+
def range = "#{file}:#{line}-#{finish}"
|
|
32
|
+
|
|
33
|
+
def sort_key = [file, line]
|
|
34
|
+
|
|
35
|
+
def overlaps?(other) = file == other.file && line <= other.finish && other.line <= finish
|
|
36
|
+
|
|
37
|
+
def overlaps_any?(others) = others.any? { overlaps?(it) }
|
|
38
|
+
|
|
39
|
+
def nodes = @nodes ||= @roots.flat_map { Analysis::NodeWalk.collect(it) }
|
|
40
|
+
end
|
|
41
|
+
end
|
|
42
|
+
end
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class Grouping
|
|
6
|
+
def initialize(group)
|
|
7
|
+
@group = group
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def cluster
|
|
11
|
+
sites = distinct
|
|
12
|
+
Cluster.new(sites) if sites.size >= 2
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def distinct
|
|
18
|
+
@group.sort_by { -it.mass }.each_with_object([]) do |fragment, kept|
|
|
19
|
+
kept << fragment unless fragment.overlaps_any?(kept)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class Index
|
|
6
|
+
RARE = 2
|
|
7
|
+
MAX_BUCKET = 60
|
|
8
|
+
|
|
9
|
+
def initialize(fragments)
|
|
10
|
+
@fragments = fragments
|
|
11
|
+
@document_frequency = frequencies(fragments)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def buckets = grouped.values.select { |bucket| bucket.size.between?(2, MAX_BUCKET) }
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def frequencies(fragments)
|
|
19
|
+
fragments.each_with_object(Hash.new(0)) { |fragment, counts| tally(counts, fragment) }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def tally(counts, fragment) = fragment.types.uniq.each { |type| counts[type] += 1 }
|
|
23
|
+
|
|
24
|
+
def grouped
|
|
25
|
+
index = Hash.new { |hash, type| hash[type] = [] }
|
|
26
|
+
@fragments.each { |fragment| file(index, fragment) }
|
|
27
|
+
index
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def file(index, fragment) = rarest(fragment).each { |type| index[type] << fragment }
|
|
31
|
+
|
|
32
|
+
def rarest(fragment) = fragment.types.uniq.min_by(RARE) { @document_frequency[it] }
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class Maximal
|
|
6
|
+
def initialize(clusters)
|
|
7
|
+
@clusters = clusters
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def reduced
|
|
11
|
+
@clusters.sort_by { -it.mass }.each_with_object([]) do |cluster, kept|
|
|
12
|
+
kept << cluster unless shadowed_by?(cluster, kept.flat_map(&:sites))
|
|
13
|
+
end
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
def shadowed_by?(cluster, bigger) = cluster.sites.all? { it.overlaps_any?(bigger) }
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class NearMiss
|
|
6
|
+
THRESHOLD = 0.8
|
|
7
|
+
MASS_RATIO = 1.5
|
|
8
|
+
|
|
9
|
+
def initialize(fragments)
|
|
10
|
+
@fragments = fragments
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def pairs = Index.new(@fragments).buckets.flat_map { |bucket| verified(bucket) }.uniq
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def verified(bucket) = bucket.combination(2).select { |left, right| near?(left, right) }
|
|
18
|
+
|
|
19
|
+
def near?(left, right)
|
|
20
|
+
return false unless comparable?(left, right) && !left.overlaps?(right)
|
|
21
|
+
|
|
22
|
+
drifted?(left.types, right.types)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def drifted?(first, second) = first != second && Similarity.new(first, second).at_least?(THRESHOLD)
|
|
26
|
+
|
|
27
|
+
def comparable?(left, right)
|
|
28
|
+
masses = [left.mass, right.mass]
|
|
29
|
+
masses.max <= masses.min * MASS_RATIO
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|