hashira 0.2.0 → 0.4.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 +141 -0
- data/README.md +53 -5
- data/lib/hashira/analysis/catalog.rb +27 -0
- data/lib/hashira/analysis/census.rb +42 -35
- data/lib/hashira/analysis/constant_registry.rb +43 -0
- data/lib/hashira/analysis/cycle_findings.rb +34 -29
- data/lib/hashira/analysis/cycle_search.rb +28 -35
- data/lib/hashira/analysis/cycles.rb +14 -0
- data/lib/hashira/analysis/definition.rb +22 -0
- data/lib/hashira/analysis/definitions.rb +22 -18
- data/lib/hashira/analysis/edge.rb +4 -3
- data/lib/hashira/analysis/edge_map.rb +23 -26
- data/lib/hashira/analysis/finding.rb +7 -6
- data/lib/hashira/analysis/folder_placement.rb +17 -0
- data/lib/hashira/analysis/folding.rb +54 -0
- data/lib/hashira/analysis/graph.rb +35 -38
- data/lib/hashira/analysis/metric.rb +8 -7
- data/lib/hashira/analysis/namespace_placement.rb +28 -0
- data/lib/hashira/analysis/namespace_prefix.rb +32 -0
- data/lib/hashira/analysis/naming.rb +14 -0
- data/lib/hashira/analysis/no_folding.rb +13 -0
- data/lib/hashira/analysis/node_walk.rb +3 -3
- data/lib/hashira/analysis/placement.rb +19 -0
- data/lib/hashira/analysis/references.rb +14 -13
- data/lib/hashira/analysis/resolver.rb +28 -0
- data/lib/hashira/analysis/roster.rb +25 -0
- data/lib/hashira/analysis/rule.rb +10 -14
- data/lib/hashira/analysis/sdp_check.rb +8 -12
- data/lib/hashira/analysis/sdp_violation_findings.rb +20 -18
- 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 +36 -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 +15 -20
- data/lib/hashira/cli/options.rb +9 -4
- data/lib/hashira/cli/package_by.rb +14 -0
- data/lib/hashira/cli/run.rb +29 -27
- data/lib/hashira/cli/skip.rb +14 -20
- data/lib/hashira/cli/usage.rb +41 -41
- data/lib/hashira/cli.rb +17 -19
- data/lib/hashira/complexity/analyzer.rb +30 -32
- data/lib/hashira/complexity/boolean_run.rb +11 -15
- data/lib/hashira/complexity/cognitive_score.rb +61 -66
- data/lib/hashira/complexity/if_chain.rb +33 -38
- data/lib/hashira/complexity/method_finding.rb +43 -48
- 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/diagram/dot.rb +8 -11
- data/lib/hashira/diagram/mermaid.rb +11 -16
- data/lib/hashira/diagram/renderer.rb +10 -15
- data/lib/hashira/duplication/analyzer.rb +10 -14
- data/lib/hashira/duplication/cluster.rb +15 -12
- data/lib/hashira/duplication/clusterer.rb +31 -29
- data/lib/hashira/duplication/delta.rb +29 -30
- data/lib/hashira/duplication/duplication_finding.rb +19 -21
- data/lib/hashira/duplication/extractor.rb +15 -19
- data/lib/hashira/duplication/fragment.rb +19 -23
- data/lib/hashira/duplication/grouping.rb +12 -16
- 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 +28 -35
- data/lib/hashira/duplication/union_find.rb +10 -14
- data/lib/hashira/duplication/variance.rb +36 -44
- 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 +36 -31
- data/lib/hashira/project.rb +53 -32
- 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 +30 -32
- data/lib/hashira/report/metrics_table.rb +46 -33
- data/lib/hashira/report/text.rb +59 -52
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +92 -55
- metadata +16 -2
- data/lib/hashira/analysis/root_namespace.rb +0 -14
|
@@ -1,39 +1,38 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
@cluster = cluster
|
|
17
|
-
end
|
|
18
|
-
|
|
19
|
-
def summary = ADVICE.fetch(kind)
|
|
3
|
+
class Hashira::Duplication::Delta
|
|
4
|
+
ADVICE = {
|
|
5
|
+
identical: "byte-for-byte identical — extract a shared method and call it from each site.",
|
|
6
|
+
literal: "differs only in literal values — extract a method, pass them as arguments.",
|
|
7
|
+
message: "differs only in the receiver or message — extract a method taking the receiver.",
|
|
8
|
+
constant: "differs only in a constant — extract a method and parameterize it.",
|
|
9
|
+
structure: "the control flow differs — extract the common core, but verify by hand (lower confidence).",
|
|
10
|
+
mixed: "extract the shared shape and pass what differs as parameters."
|
|
11
|
+
}.freeze
|
|
12
|
+
|
|
13
|
+
def initialize(cluster)
|
|
14
|
+
@cluster = cluster
|
|
15
|
+
end
|
|
20
16
|
|
|
21
|
-
|
|
22
|
-
tags = kinds
|
|
23
|
-
return :identical if tags.empty?
|
|
24
|
-
return :structure if tags.include?(:structure)
|
|
17
|
+
def summary = ADVICE.fetch(kind)
|
|
25
18
|
|
|
26
|
-
|
|
27
|
-
|
|
19
|
+
def kind
|
|
20
|
+
tags = kinds
|
|
21
|
+
return :identical if tags.empty?
|
|
22
|
+
return :structure if tags.include?(:structure)
|
|
23
|
+
tags.size == 1 ? tags.first : :mixed
|
|
24
|
+
end
|
|
28
25
|
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
26
|
+
def to_h
|
|
27
|
+
{
|
|
28
|
+
mass: @cluster.mass, sites: @cluster.size, kind:,
|
|
29
|
+
locations: @cluster.sites.sort_by(&:rank).map(&:range)
|
|
30
|
+
}
|
|
31
|
+
end
|
|
33
32
|
|
|
34
|
-
|
|
33
|
+
private
|
|
35
34
|
|
|
36
|
-
|
|
37
|
-
|
|
35
|
+
def kinds
|
|
36
|
+
@cluster.others.flat_map { |other| Hashira::Duplication::Variance.new(@cluster.canonical, other).kinds }.uniq
|
|
38
37
|
end
|
|
39
38
|
end
|
|
@@ -1,30 +1,28 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@churn = churn
|
|
9
|
-
end
|
|
3
|
+
class Hashira::Duplication::DuplicationFinding
|
|
4
|
+
def initialize(cluster, churn)
|
|
5
|
+
@cluster = cluster
|
|
6
|
+
@churn = churn
|
|
7
|
+
end
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
9
|
+
def to_finding
|
|
10
|
+
site = @cluster.canonical
|
|
11
|
+
Hashira::Analysis::Finding.new(
|
|
12
|
+
kind: "duplication", package: site.location, digest: site.digest,
|
|
13
|
+
cycle: nil, message:, evidence:
|
|
14
|
+
)
|
|
15
|
+
end
|
|
16
16
|
|
|
17
|
-
|
|
17
|
+
private
|
|
18
18
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
19
|
+
def message
|
|
20
|
+
"#{@cluster.size} similar fragments (mass #{@cluster.mass}) — #{Hashira::Duplication::Delta.new(@cluster).summary}#{note}"
|
|
21
|
+
end
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
def evidence = @cluster.sites.sort_by(&:rank).map(&:range)
|
|
24
24
|
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
end
|
|
28
|
-
end
|
|
25
|
+
def note
|
|
26
|
+
@churn.hot?(@cluster.sites) ? " Both sites change often — fix one, miss the other." : ""
|
|
29
27
|
end
|
|
30
28
|
end
|
|
@@ -2,30 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class Extractor
|
|
8
|
-
WHOLE = [Prism::DefNode, Prism::WhenNode, Prism::RescueNode].freeze
|
|
5
|
+
class Hashira::Duplication::Extractor
|
|
6
|
+
WHOLE = [Prism::DefNode, Prism::WhenNode, Prism::RescueNode].freeze
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
def initialize(project, trees)
|
|
9
|
+
@project = project
|
|
10
|
+
@fragments = trees.flat_map { |path, tree| scan(@project.relative(path), tree) }
|
|
11
|
+
end
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
attr_reader :fragments
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
private
|
|
18
16
|
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
17
|
+
def scan(rel, tree)
|
|
18
|
+
nodes = Hashira::Analysis::NodeWalk.collect(tree)
|
|
19
|
+
windows(rel, nodes) + wholes(nodes).map { Hashira::Duplication::Fragment.new(rel, [it]) }
|
|
20
|
+
end
|
|
23
21
|
|
|
24
|
-
|
|
22
|
+
def windows(rel, nodes) = runs(nodes).flat_map { Hashira::Duplication::Sequence.new(rel, it).fragments }
|
|
25
23
|
|
|
26
|
-
|
|
24
|
+
def runs(nodes) = nodes.filter_map { it.body if it.is_a?(Prism::StatementsNode) }
|
|
27
25
|
|
|
28
|
-
|
|
29
|
-
end
|
|
30
|
-
end
|
|
26
|
+
def wholes(nodes) = nodes.select { WHOLE.include?(it.class) }
|
|
31
27
|
end
|
|
@@ -2,41 +2,37 @@
|
|
|
2
2
|
|
|
3
3
|
require "digest"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
class Fragment
|
|
8
|
-
DIGEST_LENGTH = 12
|
|
5
|
+
class Hashira::Duplication::Fragment
|
|
6
|
+
DIGEST_LENGTH = 12
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
def initialize(file, roots)
|
|
9
|
+
@file = file
|
|
10
|
+
@roots = roots
|
|
11
|
+
end
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
attr_reader :file
|
|
16
14
|
|
|
17
|
-
|
|
15
|
+
def types = @types ||= nodes.map(&:type)
|
|
18
16
|
|
|
19
|
-
|
|
17
|
+
def digest = Digest::SHA256.hexdigest(shape).slice(0, DIGEST_LENGTH)
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
def shape = types.join(",")
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
def mass = types.size
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
def line = @roots.first.location.start_line
|
|
26
24
|
|
|
27
|
-
|
|
25
|
+
def finish = @roots.last.location.end_line
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
def location = "#{file}:#{line}"
|
|
30
28
|
|
|
31
|
-
|
|
29
|
+
def range = "#{file}:#{line}-#{finish}"
|
|
32
30
|
|
|
33
|
-
|
|
31
|
+
def rank = [file, line]
|
|
34
32
|
|
|
35
|
-
|
|
33
|
+
def overlaps?(other) = file == other.file && line <= other.finish && other.line <= finish
|
|
36
34
|
|
|
37
|
-
|
|
35
|
+
def touches?(others) = others.any? { overlaps?(it) }
|
|
38
36
|
|
|
39
|
-
|
|
40
|
-
end
|
|
41
|
-
end
|
|
37
|
+
def nodes = @nodes ||= @roots.flat_map { Hashira::Analysis::NodeWalk.collect(it) }
|
|
42
38
|
end
|
|
@@ -1,24 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@group = group
|
|
8
|
-
end
|
|
3
|
+
class Hashira::Duplication::Grouping
|
|
4
|
+
def initialize(group)
|
|
5
|
+
@group = group
|
|
6
|
+
end
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
8
|
+
def cluster
|
|
9
|
+
sites = distinct
|
|
10
|
+
Hashira::Duplication::Cluster.new(sites) if sites.size >= 2
|
|
11
|
+
end
|
|
14
12
|
|
|
15
|
-
|
|
13
|
+
private
|
|
16
14
|
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
end
|
|
21
|
-
end
|
|
15
|
+
def distinct
|
|
16
|
+
@group.sort_by { -it.mass }.each_with_object([]) do |fragment, kept|
|
|
17
|
+
kept << fragment unless fragment.touches?(kept)
|
|
22
18
|
end
|
|
23
19
|
end
|
|
24
20
|
end
|
|
@@ -1,35 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
RARE = 2
|
|
7
|
-
MAX_BUCKET = 60
|
|
3
|
+
class Hashira::Duplication::Index
|
|
4
|
+
RARE = 2
|
|
5
|
+
MAX_BUCKET = 60
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
7
|
+
def initialize(fragments)
|
|
8
|
+
@fragments = fragments
|
|
9
|
+
@frequency = frequencies(fragments)
|
|
10
|
+
end
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
def buckets = grouped.values.select { |bucket| bucket.size.between?(2, MAX_BUCKET) }
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
private
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
16
|
+
def frequencies(fragments)
|
|
17
|
+
fragments.each_with_object(Hash.new(0)) { |fragment, counts| tally(counts, fragment) }
|
|
18
|
+
end
|
|
21
19
|
|
|
22
|
-
|
|
20
|
+
def tally(counts, fragment) = fragment.types.uniq.each { |type| counts[type] += 1 }
|
|
23
21
|
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
22
|
+
def grouped
|
|
23
|
+
index = Hash.new { |hash, type| hash[type] = [] }
|
|
24
|
+
@fragments.each { |fragment| file(index, fragment) }
|
|
25
|
+
index
|
|
26
|
+
end
|
|
29
27
|
|
|
30
|
-
|
|
28
|
+
def file(index, fragment) = rarest(fragment).each { |type| index[type] << fragment }
|
|
31
29
|
|
|
32
|
-
|
|
33
|
-
end
|
|
34
|
-
end
|
|
30
|
+
def rarest(fragment) = fragment.types.uniq.min_by(RARE) { @frequency[it] }
|
|
35
31
|
end
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
3
|
+
class Hashira::Duplication::Maximal
|
|
4
|
+
def initialize(clusters)
|
|
5
|
+
@clusters = clusters
|
|
6
|
+
end
|
|
17
7
|
|
|
18
|
-
|
|
8
|
+
def reduced
|
|
9
|
+
@clusters.sort_by { -it.mass }.each_with_object([]) do |cluster, kept|
|
|
10
|
+
kept << cluster unless within?(cluster, kept.flat_map(&:sites))
|
|
19
11
|
end
|
|
20
12
|
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def within?(cluster, bigger) = cluster.sites.all? { it.touches?(bigger) }
|
|
21
17
|
end
|
|
@@ -1,33 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
THRESHOLD = 0.8
|
|
7
|
-
MASS_RATIO = 1.5
|
|
3
|
+
class Hashira::Duplication::NearMiss
|
|
4
|
+
THRESHOLD = 0.8
|
|
5
|
+
MASS_RATIO = 1.5
|
|
8
6
|
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
def pairs = Index.new(@fragments).buckets.flat_map { |bucket| verified(bucket) }.uniq
|
|
7
|
+
def initialize(fragments)
|
|
8
|
+
@fragments = fragments
|
|
9
|
+
end
|
|
14
10
|
|
|
15
|
-
|
|
11
|
+
def pairs = Hashira::Duplication::Index.new(@fragments).buckets.flat_map { |bucket| verified(bucket) }.uniq
|
|
16
12
|
|
|
17
|
-
|
|
13
|
+
private
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
return false unless comparable?(left, right) && !left.overlaps?(right)
|
|
15
|
+
def verified(bucket) = bucket.combination(2).select { |left, right| near?(left, right) }
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
|
|
17
|
+
def near?(left, right)
|
|
18
|
+
return false unless comparable?(left, right) && !left.overlaps?(right)
|
|
19
|
+
drifted?(left.types, right.types)
|
|
20
|
+
end
|
|
24
21
|
|
|
25
|
-
|
|
22
|
+
def drifted?(first, second)
|
|
23
|
+
first != second && Hashira::Duplication::Similarity.new(first, second).meets?(THRESHOLD)
|
|
24
|
+
end
|
|
26
25
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
end
|
|
31
|
-
end
|
|
26
|
+
def comparable?(left, right)
|
|
27
|
+
masses = [left.mass, right.mass]
|
|
28
|
+
masses.max <= masses.min * MASS_RATIO
|
|
32
29
|
end
|
|
33
30
|
end
|
|
@@ -1,34 +1,32 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
3
|
+
class Hashira::Duplication::Sequence
|
|
4
|
+
MIN_STATEMENTS = 1
|
|
5
|
+
MAX_STATEMENTS = 12
|
|
6
|
+
LIST_RUN = 3
|
|
7
|
+
|
|
8
|
+
def initialize(file, statements)
|
|
9
|
+
@file = file
|
|
10
|
+
@statements = statements
|
|
11
|
+
end
|
|
9
12
|
|
|
10
|
-
|
|
11
|
-
@file = file
|
|
12
|
-
@statements = statements
|
|
13
|
-
end
|
|
13
|
+
def fragments = segments.flat_map { windows(it) }
|
|
14
14
|
|
|
15
|
-
|
|
16
|
-
return [] if listing?
|
|
15
|
+
private
|
|
17
16
|
|
|
18
|
-
|
|
19
|
-
end
|
|
17
|
+
def segments = runs.chunk { listing?(it) }.filter_map { |listed, group| group.flatten(1) unless listed }
|
|
20
18
|
|
|
21
|
-
|
|
19
|
+
def listing?(run) = run.size >= LIST_RUN
|
|
22
20
|
|
|
23
|
-
|
|
21
|
+
def runs = shaped.slice_when { |left, right| left.last != right.last }.map { it.map(&:first) }
|
|
24
22
|
|
|
25
|
-
|
|
23
|
+
def shaped = @statements.map { [it, fragment([it]).types] }
|
|
26
24
|
|
|
27
|
-
|
|
25
|
+
def windows(segment) = lengths(segment).flat_map { |length| slide(segment, length) }
|
|
28
26
|
|
|
29
|
-
|
|
27
|
+
def lengths(segment) = MIN_STATEMENTS..[segment.size, MAX_STATEMENTS].min
|
|
30
28
|
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
29
|
+
def slide(segment, length) = (0..(segment.size - length)).map { fragment(segment[it, length]) }
|
|
30
|
+
|
|
31
|
+
def fragment(roots) = Hashira::Duplication::Fragment.new(@file, roots)
|
|
34
32
|
end
|
|
@@ -1,50 +1,43 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@right = right
|
|
9
|
-
end
|
|
10
|
-
|
|
11
|
-
def ratio
|
|
12
|
-
return 0.0 if @left.empty? || @right.empty?
|
|
13
|
-
|
|
14
|
-
normalized(lcs)
|
|
15
|
-
end
|
|
3
|
+
class Hashira::Duplication::Similarity
|
|
4
|
+
def initialize(left, right)
|
|
5
|
+
@left = left
|
|
6
|
+
@right = right
|
|
7
|
+
end
|
|
16
8
|
|
|
17
|
-
|
|
9
|
+
def ratio
|
|
10
|
+
return 0.0 if @left.empty? || @right.empty?
|
|
11
|
+
normalized(lcs)
|
|
12
|
+
end
|
|
18
13
|
|
|
19
|
-
|
|
14
|
+
def meets?(threshold) = ceiling >= threshold && ratio >= threshold
|
|
20
15
|
|
|
21
|
-
|
|
16
|
+
private
|
|
22
17
|
|
|
23
|
-
|
|
18
|
+
def ceiling = normalized(overlap)
|
|
24
19
|
|
|
25
|
-
|
|
26
|
-
counts = @right.tally
|
|
27
|
-
@left.count { taken?(counts, it) }
|
|
28
|
-
end
|
|
20
|
+
def normalized(length) = (2.0 * length) / (@left.size + @right.size)
|
|
29
21
|
|
|
30
|
-
|
|
31
|
-
|
|
22
|
+
def overlap
|
|
23
|
+
@left.count { taken?(@right.tally, it) }
|
|
24
|
+
end
|
|
32
25
|
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
26
|
+
def taken?(counts, token)
|
|
27
|
+
return false unless counts.fetch(token, 0).positive?
|
|
28
|
+
counts[token] -= 1
|
|
29
|
+
true
|
|
30
|
+
end
|
|
36
31
|
|
|
37
|
-
|
|
32
|
+
def lcs = @left.reduce(blank) { |prev, token| advance(prev, token) }.last
|
|
38
33
|
|
|
39
|
-
|
|
34
|
+
def blank = Array.new(@right.size + 1, 0)
|
|
40
35
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
36
|
+
def advance(prev, token)
|
|
37
|
+
@right.each_index.reduce([0]) { |row, index| row << cell(prev, row, token, index) }
|
|
38
|
+
end
|
|
44
39
|
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
end
|
|
48
|
-
end
|
|
40
|
+
def cell(prev, row, token, index)
|
|
41
|
+
@right[index] == token ? prev[index] + 1 : [prev[index + 1], row[index]].max
|
|
49
42
|
end
|
|
50
43
|
end
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@parent = {}
|
|
8
|
-
end
|
|
3
|
+
class Hashira::Duplication::UnionFind
|
|
4
|
+
def initialize
|
|
5
|
+
@parent = {}
|
|
6
|
+
end
|
|
9
7
|
|
|
10
|
-
|
|
8
|
+
def union(left, right) = @parent[root(left)] = root(right)
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
def clusters = @parent.keys.group_by { root(it) }.values
|
|
13
11
|
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
end
|
|
19
|
-
end
|
|
12
|
+
def root(node)
|
|
13
|
+
@parent[node] = node unless @parent.key?(node)
|
|
14
|
+
found = @parent[node]
|
|
15
|
+
found == node ? node : (@parent[node] = root(found))
|
|
20
16
|
end
|
|
21
17
|
end
|