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.
Files changed (72) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +97 -0
  3. data/README.md +334 -32
  4. data/lib/hashira/analysis/census.rb +12 -14
  5. data/lib/hashira/analysis/constant_registry.rb +40 -0
  6. data/lib/hashira/analysis/cycle_findings.rb +1 -1
  7. data/lib/hashira/analysis/cycle_search.rb +1 -1
  8. data/lib/hashira/analysis/definitions.rb +1 -1
  9. data/lib/hashira/analysis/finding.rb +6 -2
  10. data/lib/hashira/analysis/graph.rb +3 -3
  11. data/lib/hashira/analysis/namespace_prefix.rb +32 -0
  12. data/lib/hashira/analysis/node_walk.rb +7 -1
  13. data/lib/hashira/analysis/references.rb +2 -2
  14. data/lib/hashira/analysis/sdp_check.rb +1 -1
  15. data/lib/hashira/analysis/type_walk.rb +1 -1
  16. data/lib/hashira/churn.rb +22 -0
  17. data/lib/hashira/ci/accepted.rb +11 -7
  18. data/lib/hashira/ci/baseline.rb +40 -0
  19. data/lib/hashira/ci/diff.rb +15 -0
  20. data/lib/hashira/ci/edge_diff_report.rb +4 -27
  21. data/lib/hashira/ci/finding_diff_report.rb +26 -0
  22. data/lib/hashira/ci/gate.rb +7 -5
  23. data/lib/hashira/ci/improvement.rb +19 -0
  24. data/lib/hashira/ci/ratchet.rb +16 -20
  25. data/lib/hashira/ci/ratchet_report.rb +42 -0
  26. data/lib/hashira/cli/command_line.rb +12 -6
  27. data/lib/hashira/cli/fail_on.rb +4 -2
  28. data/lib/hashira/cli/options.rb +1 -1
  29. data/lib/hashira/cli/run.rb +9 -3
  30. data/lib/hashira/cli/skip.rb +27 -0
  31. data/lib/hashira/cli/usage.rb +10 -5
  32. data/lib/hashira/cli.rb +1 -1
  33. data/lib/hashira/complexity/analyzer.rb +45 -0
  34. data/lib/hashira/complexity/boolean_run.rb +22 -0
  35. data/lib/hashira/complexity/cognitive_score.rb +72 -0
  36. data/lib/hashira/complexity/if_chain.rb +45 -0
  37. data/lib/hashira/complexity/method_finding.rb +52 -0
  38. data/lib/hashira/complexity/method_score.rb +17 -0
  39. data/lib/hashira/complexity/rescue_scan.rb +28 -0
  40. data/lib/hashira/complexity/rollup.rb +22 -0
  41. data/lib/hashira/duplication/analyzer.rb +21 -0
  42. data/lib/hashira/duplication/cluster.rb +26 -0
  43. data/lib/hashira/duplication/clusterer.rb +50 -0
  44. data/lib/hashira/duplication/delta.rb +39 -0
  45. data/lib/hashira/duplication/duplication_finding.rb +30 -0
  46. data/lib/hashira/duplication/extractor.rb +31 -0
  47. data/lib/hashira/duplication/fragment.rb +42 -0
  48. data/lib/hashira/duplication/grouping.rb +24 -0
  49. data/lib/hashira/duplication/index.rb +35 -0
  50. data/lib/hashira/duplication/maximal.rb +21 -0
  51. data/lib/hashira/duplication/near_miss.rb +33 -0
  52. data/lib/hashira/duplication/sequence.rb +36 -0
  53. data/lib/hashira/duplication/similarity.rb +50 -0
  54. data/lib/hashira/duplication/union_find.rb +21 -0
  55. data/lib/hashira/duplication/variance.rb +57 -0
  56. data/lib/hashira/hotspots/file_cost.rb +19 -0
  57. data/lib/hashira/hotspots/rollup.rb +33 -0
  58. data/lib/hashira/pipeline.rb +28 -6
  59. data/lib/hashira/project.rb +32 -9
  60. data/lib/hashira/report/complexity_table.rb +40 -0
  61. data/lib/hashira/report/dependency_map.rb +8 -5
  62. data/lib/hashira/report/finding_lines.rb +1 -1
  63. data/lib/hashira/report/graph_payload.rb +29 -0
  64. data/lib/hashira/report/hotspot_table.rb +41 -0
  65. data/lib/hashira/report/json.rb +24 -17
  66. data/lib/hashira/report/metrics_table.rb +1 -2
  67. data/lib/hashira/report/text.rb +24 -14
  68. data/lib/hashira/report/view.rb +7 -0
  69. data/lib/hashira/version.rb +1 -1
  70. data/lib/hashira.rb +38 -1
  71. metadata +46 -7
  72. 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