hashira 0.1.0 → 0.2.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 +53 -0
- data/README.md +327 -32
- data/lib/hashira/analysis/census.rb +0 -2
- 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/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 +24 -0
- data/lib/hashira/duplication/clusterer.rb +44 -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 +34 -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 +10 -7
- 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 +36 -0
- metadata +44 -6
|
@@ -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,24 @@
|
|
|
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 mass = canonical.mass
|
|
16
|
+
|
|
17
|
+
def size = sites.size
|
|
18
|
+
|
|
19
|
+
def site_masses = sites.map { [it.file, mass] }
|
|
20
|
+
|
|
21
|
+
def shape_only? = others.all? { Variance.new(canonical, it).shape_only? }
|
|
22
|
+
end
|
|
23
|
+
end
|
|
24
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
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.select { |cluster| cluster.mass >= floor(cluster) }
|
|
28
|
+
|
|
29
|
+
def built = @sets.clusters.filter_map { |group| Grouping.new(group).cluster }
|
|
30
|
+
|
|
31
|
+
def floor(cluster) = base(cluster) + idiom_penalty(cluster)
|
|
32
|
+
|
|
33
|
+
def base(cluster) = thin_evidence?(cluster) ? NEAR_MASS : BASE_MASS
|
|
34
|
+
|
|
35
|
+
def thin_evidence?(cluster) = !one_shape?(cluster) || cluster.shape_only?
|
|
36
|
+
|
|
37
|
+
def idiom_penalty(cluster) = recurrences(cluster) * PENALTY_PER_RECURRENCE
|
|
38
|
+
|
|
39
|
+
def recurrences(cluster) = [cluster.size - PAIR, 0].max
|
|
40
|
+
|
|
41
|
+
def one_shape?(cluster) = cluster.sites.map(&:types).uniq.size == 1
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
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
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class Sequence
|
|
6
|
+
MIN_STATEMENTS = 1
|
|
7
|
+
MAX_STATEMENTS = 12
|
|
8
|
+
LIST_RUN = 3
|
|
9
|
+
|
|
10
|
+
def initialize(file, statements)
|
|
11
|
+
@file = file
|
|
12
|
+
@statements = statements
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def fragments
|
|
16
|
+
return [] if listing?
|
|
17
|
+
|
|
18
|
+
lengths.flat_map { |length| slide(length) }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def listing? = @statements.size >= LIST_RUN && shapes.uniq.size == 1
|
|
24
|
+
|
|
25
|
+
def shapes = @statements.map { fragment([it]).types }
|
|
26
|
+
|
|
27
|
+
def lengths = MIN_STATEMENTS..[@statements.size, MAX_STATEMENTS].min
|
|
28
|
+
|
|
29
|
+
def slide(length) = (0..(@statements.size - length)).map { fragment(@statements[it, length]) }
|
|
30
|
+
|
|
31
|
+
def fragment(roots) = Fragment.new(@file, roots)
|
|
32
|
+
end
|
|
33
|
+
end
|
|
34
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class Similarity
|
|
6
|
+
def initialize(left, right)
|
|
7
|
+
@left = left
|
|
8
|
+
@right = right
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def ratio
|
|
12
|
+
return 0.0 if @left.empty? || @right.empty?
|
|
13
|
+
|
|
14
|
+
normalized(lcs)
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def at_least?(threshold) = upper_bound >= threshold && ratio >= threshold
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def upper_bound = normalized(tokens_in_common)
|
|
22
|
+
|
|
23
|
+
def normalized(length) = (2.0 * length) / (@left.size + @right.size)
|
|
24
|
+
|
|
25
|
+
def tokens_in_common
|
|
26
|
+
counts = @right.tally
|
|
27
|
+
@left.count { taken?(counts, it) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def taken?(counts, token)
|
|
31
|
+
return false unless counts.fetch(token, 0).positive?
|
|
32
|
+
|
|
33
|
+
counts[token] -= 1
|
|
34
|
+
true
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def lcs = @left.reduce(blank) { |prev, token| next_row(prev, token) }.last
|
|
38
|
+
|
|
39
|
+
def blank = Array.new(@right.size + 1, 0)
|
|
40
|
+
|
|
41
|
+
def next_row(prev, token)
|
|
42
|
+
@right.each_index.reduce([0]) { |row, index| row << cell(prev, row, token, index) }
|
|
43
|
+
end
|
|
44
|
+
|
|
45
|
+
def cell(prev, row, token, index)
|
|
46
|
+
@right[index] == token ? prev[index] + 1 : [prev[index + 1], row[index]].max
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class UnionFind
|
|
6
|
+
def initialize
|
|
7
|
+
@parent = {}
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def union(left, right) = @parent[root(left)] = root(right)
|
|
11
|
+
|
|
12
|
+
def clusters = @parent.keys.group_by { root(it) }.values
|
|
13
|
+
|
|
14
|
+
def root(node)
|
|
15
|
+
@parent[node] = node unless @parent.key?(node)
|
|
16
|
+
found = @parent[node]
|
|
17
|
+
found == node ? node : (@parent[node] = root(found))
|
|
18
|
+
end
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Duplication
|
|
5
|
+
class Variance
|
|
6
|
+
LITERALS = %i[integer_node float_node string_node symbol_node].freeze
|
|
7
|
+
VALUED = %i[integer_node float_node].freeze
|
|
8
|
+
NAMED = %i[call_node constant_read_node constant_path_node
|
|
9
|
+
local_variable_read_node local_variable_write_node
|
|
10
|
+
instance_variable_read_node instance_variable_write_node].freeze
|
|
11
|
+
CONSTANTS = %i[constant_read_node constant_path_node].freeze
|
|
12
|
+
|
|
13
|
+
def initialize(canonical, other)
|
|
14
|
+
@canonical = canonical
|
|
15
|
+
@other = other
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def kinds
|
|
19
|
+
return [:structure] if @canonical.types != @other.types
|
|
20
|
+
|
|
21
|
+
differing.map { |node| category(node) }.uniq
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def shape_only?
|
|
25
|
+
return false unless @canonical.types == @other.types
|
|
26
|
+
|
|
27
|
+
named.any? && named.all? { |left, right| left.name != right.name }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
private
|
|
31
|
+
|
|
32
|
+
def pairs = @canonical.nodes.zip(@other.nodes)
|
|
33
|
+
|
|
34
|
+
def named = @named ||= pairs.select { |left, _| NAMED.include?(left.type) }
|
|
35
|
+
|
|
36
|
+
def differing = pairs.select { |pair| varies?(*pair) }.map(&:first)
|
|
37
|
+
|
|
38
|
+
def varies?(left, right) = signature(left) != signature(right)
|
|
39
|
+
|
|
40
|
+
def category(node)
|
|
41
|
+
type = node.type
|
|
42
|
+
return :literal if LITERALS.include?(type)
|
|
43
|
+
|
|
44
|
+
CONSTANTS.include?(type) ? :constant : :message
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def signature(node)
|
|
48
|
+
type = node.type
|
|
49
|
+
return literal(node) if LITERALS.include?(type)
|
|
50
|
+
|
|
51
|
+
node.name if NAMED.include?(type)
|
|
52
|
+
end
|
|
53
|
+
|
|
54
|
+
def literal(node) = VALUED.include?(node.type) ? node.value : node.unescaped
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|