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
|
@@ -2,71 +2,66 @@
|
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
@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
|
|
5
|
+
class Hashira::Complexity::CognitiveScore
|
|
6
|
+
HANDLERS = {
|
|
7
|
+
Prism::IfNode => :on_if,
|
|
8
|
+
Prism::UnlessNode => :on_nester,
|
|
9
|
+
Prism::WhileNode => :on_nester,
|
|
10
|
+
Prism::UntilNode => :on_nester,
|
|
11
|
+
Prism::ForNode => :on_nester,
|
|
12
|
+
Prism::CaseNode => :on_nester,
|
|
13
|
+
Prism::CaseMatchNode => :on_nester,
|
|
14
|
+
Prism::BeginNode => :on_begin,
|
|
15
|
+
Prism::AndNode => :on_boolean,
|
|
16
|
+
Prism::OrNode => :on_boolean,
|
|
17
|
+
Prism::BlockNode => :on_block,
|
|
18
|
+
Prism::CallNode => :on_call
|
|
19
|
+
}.freeze
|
|
20
|
+
|
|
21
|
+
LABELS = {
|
|
22
|
+
Prism::UnlessNode => "unless", Prism::WhileNode => "while",
|
|
23
|
+
Prism::UntilNode => "until", Prism::ForNode => "for",
|
|
24
|
+
Prism::CaseNode => "case", Prism::CaseMatchNode => "case"
|
|
25
|
+
}.freeze
|
|
26
|
+
|
|
27
|
+
def initialize(def_node)
|
|
28
|
+
@increments = []
|
|
29
|
+
@calls = 0
|
|
30
|
+
visit(def_node.body, 0)
|
|
71
31
|
end
|
|
32
|
+
|
|
33
|
+
attr_reader :increments, :calls
|
|
34
|
+
|
|
35
|
+
def total = @increments.sum(&:cost)
|
|
36
|
+
|
|
37
|
+
def visit(node, nesting)
|
|
38
|
+
return unless node
|
|
39
|
+
__send__(HANDLERS.fetch(node.class, :descend), node, nesting)
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def add(node, cost, label)
|
|
43
|
+
@increments << Hashira::Complexity::Increment.new(line: node.location.start_line, cost:, label:)
|
|
44
|
+
end
|
|
45
|
+
|
|
46
|
+
private
|
|
47
|
+
|
|
48
|
+
def descend(node, nesting) = node.compact_child_nodes.each { visit(it, nesting) }
|
|
49
|
+
|
|
50
|
+
def on_call(node, nesting)
|
|
51
|
+
@calls += 1
|
|
52
|
+
descend(node, nesting)
|
|
53
|
+
end
|
|
54
|
+
|
|
55
|
+
def on_block(node, nesting) = node.compact_child_nodes.each { visit(it, nesting + 1) }
|
|
56
|
+
|
|
57
|
+
def on_nester(node, nesting)
|
|
58
|
+
add(node, 1 + nesting, LABELS.fetch(node.class))
|
|
59
|
+
node.compact_child_nodes.each { visit(it, nesting + 1) }
|
|
60
|
+
end
|
|
61
|
+
|
|
62
|
+
def on_if(node, nesting) = Hashira::Complexity::IfChain.new(self).apply(node, nesting)
|
|
63
|
+
|
|
64
|
+
def on_begin(node, nesting) = Hashira::Complexity::RescueScan.new(self).apply(node, nesting)
|
|
65
|
+
|
|
66
|
+
def on_boolean(node, nesting) = Hashira::Complexity::BooleanRun.new(self).apply(node, nesting)
|
|
72
67
|
end
|
|
@@ -2,44 +2,39 @@
|
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
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
|
|
5
|
+
class Hashira::Complexity::IfChain
|
|
6
|
+
def initialize(scorer)
|
|
7
|
+
@scorer = scorer
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def apply(node, nesting)
|
|
11
|
+
return ternary(node, nesting) unless node.if_keyword
|
|
12
|
+
branch(node, 1 + nesting, nesting, "if")
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
private
|
|
16
|
+
|
|
17
|
+
def branch(node, cost, nesting, label)
|
|
18
|
+
@scorer.add(node, cost, label)
|
|
19
|
+
@scorer.visit(node.predicate, nesting)
|
|
20
|
+
@scorer.visit(node.statements, nesting + 1)
|
|
21
|
+
tail(node.subsequent, nesting)
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def tail(node, nesting)
|
|
25
|
+
case node
|
|
26
|
+
when Prism::IfNode then branch(node, 1, nesting, "elsif")
|
|
27
|
+
when Prism::ElseNode then otherwise(node, nesting)
|
|
43
28
|
end
|
|
44
29
|
end
|
|
30
|
+
|
|
31
|
+
def otherwise(node, nesting)
|
|
32
|
+
@scorer.add(node, 1, "else")
|
|
33
|
+
@scorer.visit(node.statements, nesting + 1)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def ternary(node, nesting)
|
|
37
|
+
@scorer.add(node, 1, "ternary")
|
|
38
|
+
node.compact_child_nodes.each { @scorer.visit(it, nesting) }
|
|
39
|
+
end
|
|
45
40
|
end
|
|
@@ -1,52 +1,47 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def dominant
|
|
48
|
-
@score.increments.group_by(&:label).transform_values { it.sum(&:cost) }.max_by(&:last).first
|
|
49
|
-
end
|
|
50
|
-
end
|
|
3
|
+
class Hashira::Complexity::MethodFinding
|
|
4
|
+
ADVICE = {
|
|
5
|
+
"if" => "flatten the branching — guard clauses, early returns, or polymorphism.",
|
|
6
|
+
"elsif" => "replace the elsif ladder with a lookup or polymorphic dispatch.",
|
|
7
|
+
"else" => "flatten the branching — guard clauses, early returns, or polymorphism.",
|
|
8
|
+
"case" => "a case this size often wants polymorphism or a dispatch table.",
|
|
9
|
+
"boolean" => "name the compound condition in a predicate method.",
|
|
10
|
+
"rescue" => "narrow the rescue, or lift error handling to the caller.",
|
|
11
|
+
"while" => "extract the loop body into its own method.",
|
|
12
|
+
"until" => "extract the loop body into its own method.",
|
|
13
|
+
"for" => "extract the loop body into its own method.",
|
|
14
|
+
"unless" => "invert to a guard clause or a named predicate.",
|
|
15
|
+
"ternary" => "extract the nested ternary into a named method."
|
|
16
|
+
}.freeze
|
|
17
|
+
|
|
18
|
+
def initialize(score)
|
|
19
|
+
@score = score
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def to_finding
|
|
23
|
+
Hashira::Analysis::Finding.new(kind: "complexity", package: @score.subject, cycle: nil, message:, evidence:)
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
private
|
|
27
|
+
|
|
28
|
+
def message
|
|
29
|
+
"#{@score.subject} — cognitive #{@score.cognitive}, #{@score.calls} calls " \
|
|
30
|
+
"(#{@score.file}:#{@score.line}). #{advice}"
|
|
31
|
+
end
|
|
32
|
+
|
|
33
|
+
def evidence
|
|
34
|
+
@score.increments.group_by(&:label).map { |label, incs| lines(label, incs) }
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def lines(label, incs)
|
|
38
|
+
lines = incs.map(&:line).uniq
|
|
39
|
+
"#{label} +#{incs.sum(&:cost)} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def advice = ADVICE.fetch(dominant)
|
|
43
|
+
|
|
44
|
+
def dominant
|
|
45
|
+
@score.increments.group_by(&:label).transform_values { it.sum(&:cost) }.max_by(&:last).first
|
|
51
46
|
end
|
|
52
47
|
end
|
|
@@ -4,14 +4,16 @@ module Hashira
|
|
|
4
4
|
module Complexity
|
|
5
5
|
Increment = Data.define(:line, :cost, :label)
|
|
6
6
|
|
|
7
|
-
MethodScore =
|
|
8
|
-
|
|
7
|
+
MethodScore =
|
|
8
|
+
Data.define(:subject, :file, :line, :cognitive, :calls, :increments) do
|
|
9
|
+
def to_h = { subject:, file:, line:, cognitive:, calls: }
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
11
|
+
def cells = [subject, cognitive, calls, "#{file}:#{line}"]
|
|
12
|
+
end
|
|
12
13
|
|
|
13
|
-
ClassScore =
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
ClassScore =
|
|
15
|
+
Data.define(:name, :cognitive, :method_count, :peak) do
|
|
16
|
+
def cells = [name, cognitive, method_count, peak]
|
|
17
|
+
end
|
|
16
18
|
end
|
|
17
19
|
end
|
|
@@ -1,28 +1,23 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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
|
|
3
|
+
class Hashira::Complexity::RescueScan
|
|
4
|
+
def initialize(scorer)
|
|
5
|
+
@scorer = scorer
|
|
6
|
+
end
|
|
16
7
|
|
|
17
|
-
|
|
8
|
+
def apply(node, nesting)
|
|
9
|
+
@scorer.visit(node.statements, nesting)
|
|
10
|
+
clauses(node.rescue_clause, nesting)
|
|
11
|
+
@scorer.visit(node.else_clause, nesting)
|
|
12
|
+
@scorer.visit(node.ensure_clause, nesting)
|
|
13
|
+
end
|
|
18
14
|
|
|
19
|
-
|
|
20
|
-
return unless node
|
|
15
|
+
private
|
|
21
16
|
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
17
|
+
def clauses(node, nesting)
|
|
18
|
+
return unless node
|
|
19
|
+
@scorer.add(node, 1 + nesting, "rescue")
|
|
20
|
+
@scorer.visit(node.statements, nesting + 1)
|
|
21
|
+
clauses(node.subsequent, nesting)
|
|
27
22
|
end
|
|
28
23
|
end
|
|
@@ -1,22 +1,20 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@scores = scores
|
|
8
|
-
end
|
|
9
|
-
|
|
10
|
-
def classes = @scores.group_by { class_name(it.subject) }.map { |name, group| score(name, group) }
|
|
3
|
+
class Hashira::Complexity::Rollup
|
|
4
|
+
def initialize(scores)
|
|
5
|
+
@scores = scores
|
|
6
|
+
end
|
|
11
7
|
|
|
12
|
-
|
|
8
|
+
def classes = @scores.group_by { owner(it.subject) }.map { |name, group| score(name, group) }
|
|
13
9
|
|
|
14
|
-
|
|
15
|
-
ClassScore.new(name:, cognitive: group.sum(&:cognitive), method_count: group.size,
|
|
16
|
-
peak: group.map(&:cognitive).max)
|
|
17
|
-
end
|
|
10
|
+
private
|
|
18
11
|
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
def score(name, group)
|
|
13
|
+
Hashira::Complexity::ClassScore.new(
|
|
14
|
+
name:, cognitive: group.sum(&:cognitive), method_count: group.size,
|
|
15
|
+
peak: group.map(&:cognitive).max
|
|
16
|
+
)
|
|
21
17
|
end
|
|
18
|
+
|
|
19
|
+
def owner(subject) = subject.split(/[#.]/, 2).first
|
|
22
20
|
end
|
data/lib/hashira/diagram/dot.rb
CHANGED
|
@@ -1,16 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
@edges = edges
|
|
8
|
-
end
|
|
3
|
+
class Hashira::Diagram::Dot
|
|
4
|
+
def initialize(edges)
|
|
5
|
+
@edges = edges
|
|
6
|
+
end
|
|
9
7
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
end
|
|
8
|
+
def source
|
|
9
|
+
"digraph hashira {\n rankdir=LR;\n#{@edges.map do |from, to, weight|
|
|
10
|
+
%( "#{from}" -> "#{to}" [label="#{weight}"];)
|
|
11
|
+
end.join("\n")}\n}"
|
|
15
12
|
end
|
|
16
13
|
end
|
|
@@ -1,23 +1,18 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
@nodes = {}
|
|
9
|
-
end
|
|
3
|
+
class Hashira::Diagram::Mermaid
|
|
4
|
+
def initialize(edges)
|
|
5
|
+
@edges = edges
|
|
6
|
+
@nodes = {}
|
|
7
|
+
end
|
|
10
8
|
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
end
|
|
9
|
+
def source
|
|
10
|
+
"graph LR\n#{@edges.map { |from, to, weight| " #{node(from)} -->|#{weight}| #{node(to)}" }.join("\n")}"
|
|
11
|
+
end
|
|
15
12
|
|
|
16
|
-
|
|
13
|
+
private
|
|
17
14
|
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
end
|
|
21
|
-
end
|
|
15
|
+
def node(package)
|
|
16
|
+
@nodes[package] ||= "#{package.gsub(/\W/, "_")}[\"#{package}\"]"
|
|
22
17
|
end
|
|
23
18
|
end
|
|
@@ -1,20 +1,15 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@io = io
|
|
10
|
-
end
|
|
3
|
+
class Hashira::Diagram::Renderer
|
|
4
|
+
def initialize(graph, format, io: $stdout)
|
|
5
|
+
@graph = graph
|
|
6
|
+
@format = format
|
|
7
|
+
@io = io
|
|
8
|
+
end
|
|
11
9
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
0
|
|
17
|
-
end
|
|
18
|
-
end
|
|
10
|
+
def display
|
|
11
|
+
edges = @graph.weighted
|
|
12
|
+
@io.puts((@format == :dot ? Hashira::Diagram::Dot.new(edges) : Hashira::Diagram::Mermaid.new(edges)).source)
|
|
13
|
+
0
|
|
19
14
|
end
|
|
20
15
|
end
|
|
@@ -1,21 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
@churn = churn
|
|
10
|
-
end
|
|
3
|
+
class Hashira::Duplication::Analyzer
|
|
4
|
+
def initialize(project, trees, churn)
|
|
5
|
+
@project = project
|
|
6
|
+
@trees = trees
|
|
7
|
+
@churn = churn
|
|
8
|
+
end
|
|
11
9
|
|
|
12
|
-
|
|
10
|
+
def clusters = @clusters ||= Hashira::Duplication::Clusterer.new(fragments).clusters.sort_by { -it.mass }
|
|
13
11
|
|
|
14
|
-
|
|
12
|
+
def findings = clusters.map { |cluster| Hashira::Duplication::DuplicationFinding.new(cluster, @churn).to_finding }
|
|
15
13
|
|
|
16
|
-
|
|
14
|
+
private
|
|
17
15
|
|
|
18
|
-
|
|
19
|
-
end
|
|
20
|
-
end
|
|
16
|
+
def fragments = Hashira::Duplication::Extractor.new(@project, @trees).fragments
|
|
21
17
|
end
|
|
@@ -2,23 +2,26 @@
|
|
|
2
2
|
|
|
3
3
|
module Hashira
|
|
4
4
|
module Duplication
|
|
5
|
-
Cluster =
|
|
6
|
-
|
|
5
|
+
Cluster =
|
|
6
|
+
Data.define(:sites) do
|
|
7
|
+
def canonical = sites.max_by { |site| [shapes(site), site.mass] }
|
|
7
8
|
|
|
8
|
-
|
|
9
|
+
def shapes(site) = sites.count { |other| other.types == site.types }
|
|
9
10
|
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
11
|
+
def others
|
|
12
|
+
chosen = canonical
|
|
13
|
+
sites.reject { |site| site.equal?(chosen) }
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def identical = sites.select { it.types == canonical.types }
|
|
14
17
|
|
|
15
|
-
|
|
18
|
+
def mass = canonical.mass
|
|
16
19
|
|
|
17
|
-
|
|
20
|
+
def size = sites.size
|
|
18
21
|
|
|
19
|
-
|
|
22
|
+
def masses = sites.map { [it.file, mass] }
|
|
20
23
|
|
|
21
|
-
|
|
22
|
-
|
|
24
|
+
def structural? = others.all? { Variance.new(canonical, it).structural? }
|
|
25
|
+
end
|
|
23
26
|
end
|
|
24
27
|
end
|
|
@@ -1,44 +1,46 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
3
|
+
class Hashira::Duplication::Clusterer
|
|
4
|
+
PREFILTER = 12
|
|
5
|
+
BASE_MASS = 16
|
|
6
|
+
NEAR_MASS = 40
|
|
7
|
+
PAIR = 2
|
|
8
|
+
PENALTY_PER_RECURRENCE = 2
|
|
9
|
+
|
|
10
|
+
def initialize(fragments)
|
|
11
|
+
@fragments = fragments.select { |fragment| fragment.mass >= PREFILTER }
|
|
12
|
+
@sets = Hashira::Duplication::UnionFind.new
|
|
13
|
+
end
|
|
11
14
|
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
15
|
+
def clusters
|
|
16
|
+
@fragments.group_by(&:types).each_value { |group| chain(group) }
|
|
17
|
+
Hashira::Duplication::NearMiss.new(@fragments).pairs.each { |left, right| @sets.union(left, right) }
|
|
18
|
+
Hashira::Duplication::Maximal.new(sized).reduced
|
|
19
|
+
end
|
|
16
20
|
|
|
17
|
-
|
|
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
|
|
21
|
+
private
|
|
22
22
|
|
|
23
|
-
|
|
23
|
+
def chain(group) = group.each_cons(2) { |left, right| @sets.union(left, right) }
|
|
24
24
|
|
|
25
|
-
|
|
25
|
+
def sized = built.filter_map { admitted(it) }
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
def admitted(cluster) = [cluster, core(cluster)].compact.find { fits?(it) }
|
|
28
28
|
|
|
29
|
-
|
|
29
|
+
def fits?(cluster) = cluster.mass >= floor(cluster)
|
|
30
30
|
|
|
31
|
-
|
|
31
|
+
def core(cluster) = Hashira::Duplication::Grouping.new(cluster.identical).cluster
|
|
32
32
|
|
|
33
|
-
|
|
33
|
+
def built = @sets.clusters.filter_map { |group| Hashira::Duplication::Grouping.new(group).cluster }
|
|
34
34
|
|
|
35
|
-
|
|
35
|
+
def floor(cluster) = base(cluster) + penalty(cluster)
|
|
36
36
|
|
|
37
|
-
|
|
37
|
+
def base(cluster) = thin?(cluster) ? NEAR_MASS : BASE_MASS
|
|
38
38
|
|
|
39
|
-
|
|
39
|
+
def thin?(cluster) = !uniform?(cluster) || cluster.structural?
|
|
40
40
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
41
|
+
def penalty(cluster) = recurrences(cluster) * PENALTY_PER_RECURRENCE
|
|
42
|
+
|
|
43
|
+
def recurrences(cluster) = [cluster.size - PAIR, 0].max
|
|
44
|
+
|
|
45
|
+
def uniform?(cluster) = cluster.sites.map(&:types).uniq.size == 1
|
|
44
46
|
end
|