hashira 0.3.0 → 0.5.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 +155 -0
- data/README.md +127 -25
- data/lib/hashira/analysis/finding.rb +7 -6
- data/lib/hashira/analysis/node_walk.rb +3 -3
- 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 +32 -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 +24 -19
- data/lib/hashira/cli/options.rb +9 -4
- data/lib/hashira/cli/package_by.rb +14 -0
- data/lib/hashira/cli/run.rb +33 -27
- data/lib/hashira/cli/skip.rb +14 -20
- data/lib/hashira/cli/usage.rb +44 -41
- data/lib/hashira/cli.rb +17 -19
- data/lib/hashira/complexity/boolean_run.rb +13 -15
- data/lib/hashira/complexity/cognitive_score.rb +68 -66
- data/lib/hashira/complexity/if_chain.rb +33 -38
- data/lib/hashira/complexity/method_finding.rb +20 -42
- 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/complexity/scores.rb +44 -0
- data/lib/hashira/coupling/audiences.rb +49 -0
- data/lib/hashira/coupling/catalog.rb +27 -0
- data/lib/hashira/coupling/census.rb +55 -0
- data/lib/hashira/coupling/constant_registry.rb +47 -0
- data/lib/hashira/coupling/cycle_findings.rb +31 -0
- data/lib/hashira/coupling/cycle_search.rb +37 -0
- data/lib/hashira/coupling/cycles.rb +14 -0
- data/lib/hashira/coupling/definition.rb +22 -0
- data/lib/hashira/coupling/definitions.rb +31 -0
- data/lib/hashira/coupling/edge.rb +10 -0
- data/lib/hashira/coupling/edge_map.rb +33 -0
- data/lib/hashira/coupling/folder_placement.rb +17 -0
- data/lib/hashira/coupling/folding.rb +56 -0
- data/lib/hashira/coupling/graph.rb +63 -0
- data/lib/hashira/coupling/metric.rb +15 -0
- data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
- data/lib/hashira/coupling/namespace_placement.rb +28 -0
- data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +11 -11
- data/lib/hashira/coupling/naming.rb +14 -0
- data/lib/hashira/coupling/no_folding.rb +13 -0
- data/lib/hashira/coupling/placement.rb +19 -0
- data/lib/hashira/coupling/references.rb +55 -0
- data/lib/hashira/coupling/report.rb +28 -0
- data/lib/hashira/coupling/roll_call.rb +45 -0
- data/lib/hashira/coupling/roll_call_findings.rb +30 -0
- data/lib/hashira/coupling/roster.rb +25 -0
- data/lib/hashira/coupling/rule.rb +18 -0
- data/lib/hashira/coupling/scope.rb +28 -0
- data/lib/hashira/coupling/sdp_check.rb +14 -0
- data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
- data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
- data/lib/hashira/coupling/words.rb +29 -0
- data/lib/hashira/diagram/dot.rb +8 -11
- data/lib/hashira/diagram/mermaid.rb +11 -16
- data/lib/hashira/diagram/source.rb +15 -0
- data/lib/hashira/duplication/clones.rb +17 -0
- data/lib/hashira/duplication/cluster.rb +14 -13
- data/lib/hashira/duplication/clusters.rb +46 -0
- data/lib/hashira/duplication/delta.rb +21 -30
- data/lib/hashira/duplication/duplication_finding.rb +17 -23
- data/lib/hashira/duplication/fragment.rb +19 -23
- data/lib/hashira/duplication/grouping.rb +12 -16
- data/lib/hashira/duplication/harvest.rb +27 -0
- 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 +31 -34
- data/lib/hashira/duplication/union_find.rb +10 -14
- data/lib/hashira/duplication/variance.rb +35 -43
- 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 +41 -31
- data/lib/hashira/project.rb +50 -49
- 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 +32 -32
- data/lib/hashira/report/metrics_table.rb +46 -33
- data/lib/hashira/report/phrases.rb +98 -0
- data/lib/hashira/report/smell_phrases.rb +68 -0
- data/lib/hashira/report/text.rb +59 -52
- data/lib/hashira/smells/census.rb +32 -0
- data/lib/hashira/smells/check.rb +30 -0
- data/lib/hashira/smells/conditions.rb +44 -0
- data/lib/hashira/smells/contexts.rb +64 -0
- data/lib/hashira/smells/control_parameter.rb +22 -0
- data/lib/hashira/smells/data_clump.rb +30 -0
- data/lib/hashira/smells/duplicate_method_call.rb +54 -0
- data/lib/hashira/smells/feature_envy.rb +17 -0
- data/lib/hashira/smells/instance_variable_assumption.rb +44 -0
- data/lib/hashira/smells/manual_dispatch.rb +16 -0
- data/lib/hashira/smells/module_initialize.rb +9 -0
- data/lib/hashira/smells/nil_check.rb +32 -0
- data/lib/hashira/smells/param_check.rb +51 -0
- data/lib/hashira/smells/refs.rb +53 -0
- data/lib/hashira/smells/repeated_conditional.rb +29 -0
- data/lib/hashira/smells/report.rb +31 -0
- data/lib/hashira/smells/scope.rb +29 -0
- data/lib/hashira/smells/too_many_instance_variables.rb +24 -0
- data/lib/hashira/smells/utility_function.rb +13 -0
- data/lib/hashira/smells/visibility.rb +64 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +131 -60
- metadata +62 -20
- data/lib/hashira/analysis/census.rb +0 -39
- data/lib/hashira/analysis/constant_registry.rb +0 -40
- data/lib/hashira/analysis/cycle_findings.rb +0 -33
- data/lib/hashira/analysis/cycle_search.rb +0 -44
- data/lib/hashira/analysis/definitions.rb +0 -27
- data/lib/hashira/analysis/edge.rb +0 -9
- data/lib/hashira/analysis/edge_map.rb +0 -36
- data/lib/hashira/analysis/graph.rb +0 -56
- data/lib/hashira/analysis/metric.rb +0 -14
- data/lib/hashira/analysis/references.rb +0 -39
- data/lib/hashira/analysis/rule.rb +0 -22
- data/lib/hashira/analysis/sdp_check.rb +0 -18
- data/lib/hashira/analysis/sdp_violation_findings.rb +0 -27
- data/lib/hashira/complexity/analyzer.rb +0 -45
- data/lib/hashira/diagram/renderer.rb +0 -20
- data/lib/hashira/duplication/analyzer.rb +0 -21
- data/lib/hashira/duplication/clusterer.rb +0 -50
- data/lib/hashira/duplication/extractor.rb +0 -31
|
@@ -1,31 +1,31 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
module Hashira
|
|
4
|
-
module
|
|
4
|
+
module Coupling
|
|
5
5
|
module NamespacePrefix
|
|
6
6
|
module_function
|
|
7
7
|
|
|
8
8
|
def infer(definitions)
|
|
9
|
-
build(
|
|
9
|
+
build(definitions.map { |_node, full, _package| full }.uniq)
|
|
10
10
|
end
|
|
11
11
|
|
|
12
12
|
def build(fulls, prefix = [])
|
|
13
13
|
depth = prefix.length
|
|
14
|
-
wrapper =
|
|
14
|
+
wrapper = wrapper(fulls, depth)
|
|
15
15
|
return prefix unless wrapper
|
|
16
|
-
|
|
17
16
|
build(fulls.select { it[depth] == wrapper }, prefix + [wrapper])
|
|
18
17
|
end
|
|
19
18
|
|
|
20
|
-
def
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
.to_h.values
|
|
19
|
+
def wrapper(fulls, depth)
|
|
20
|
+
wrapper, count = fulls.filter_map { it[depth] if it[depth + 1] }.tally.max_by { |_wrapper, tally| tally }
|
|
21
|
+
wrapper if wrapper && count == peers(fulls, depth, wrapper)
|
|
24
22
|
end
|
|
25
23
|
|
|
26
|
-
def
|
|
27
|
-
|
|
28
|
-
|
|
24
|
+
def peers(fulls, depth, wrapper)
|
|
25
|
+
fulls.count do |full|
|
|
26
|
+
size = full.length
|
|
27
|
+
size > depth && !(size == depth + 1 && full[depth] == wrapper)
|
|
28
|
+
end
|
|
29
29
|
end
|
|
30
30
|
end
|
|
31
31
|
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Coupling::Naming
|
|
4
|
+
def initialize(definitions)
|
|
5
|
+
@segments = Hashira::Coupling::NamespacePrefix.infer(definitions)
|
|
6
|
+
end
|
|
7
|
+
|
|
8
|
+
attr_reader :segments
|
|
9
|
+
|
|
10
|
+
def strip(path)
|
|
11
|
+
return [] if @segments.first(path.length) == path
|
|
12
|
+
path.drop(@segments.length.downto(0).find { path.first(it) == @segments.last(it) })
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Coupling::Placement
|
|
4
|
+
def self.build(packaging, project, catalog)
|
|
5
|
+
{
|
|
6
|
+
folder: Hashira::Coupling::FolderPlacement,
|
|
7
|
+
namespace: Hashira::Coupling::NamespacePlacement
|
|
8
|
+
}.fetch(packaging).new(project, catalog)
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def initialize(project, catalog)
|
|
12
|
+
@project = project
|
|
13
|
+
@catalog = catalog
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
private
|
|
17
|
+
|
|
18
|
+
attr_reader :project, :catalog
|
|
19
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
class Hashira::Coupling::References
|
|
6
|
+
def self.list(tree) = new.sightings(tree).map(&:first)
|
|
7
|
+
|
|
8
|
+
def self.sightings(tree, roots = nil) = new(roots).sightings(tree)
|
|
9
|
+
|
|
10
|
+
def initialize(roots = nil)
|
|
11
|
+
@roots = roots
|
|
12
|
+
@found = []
|
|
13
|
+
@scopes = [[]]
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def sightings(tree)
|
|
17
|
+
collect(tree)
|
|
18
|
+
@found
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def nesting = @scopes.last
|
|
24
|
+
|
|
25
|
+
def collect(node, home = nesting)
|
|
26
|
+
return unless node
|
|
27
|
+
return @found << sighting(node, home) if constant?(node)
|
|
28
|
+
return enter(node) if definition?(node)
|
|
29
|
+
node.compact_child_nodes.each { collect(it, home) }
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
def sighting(node, home)
|
|
33
|
+
[syntax.segments(node), node.location.start_line, syntax.cbase?(node) ? nil : nesting, home]
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def syntax = Hashira::Analysis::Syntax
|
|
37
|
+
|
|
38
|
+
def constant?(node) = node.is_a?(Prism::ConstantPathNode) || node.is_a?(Prism::ConstantReadNode)
|
|
39
|
+
|
|
40
|
+
def definition?(node) = node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
41
|
+
|
|
42
|
+
def enter(node)
|
|
43
|
+
opened = nesting + [anchor(node)]
|
|
44
|
+
collect(node.superclass, opened) if node.is_a?(Prism::ClassNode)
|
|
45
|
+
inside(opened) { collect(node.body) }
|
|
46
|
+
end
|
|
47
|
+
|
|
48
|
+
def anchor(node) = syntax.anchor(nesting, syntax.segments(node.constant_path), @roots)
|
|
49
|
+
|
|
50
|
+
def inside(scope)
|
|
51
|
+
@scopes << scope
|
|
52
|
+
yield
|
|
53
|
+
@scopes.pop
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "cycle_findings"
|
|
4
|
+
require_relative "mixed_audience_findings"
|
|
5
|
+
require_relative "roll_call_findings"
|
|
6
|
+
require_relative "sdp_violation_findings"
|
|
7
|
+
require_relative "wide_edge_findings"
|
|
8
|
+
|
|
9
|
+
class Hashira::Coupling::Report
|
|
10
|
+
RULES = [
|
|
11
|
+
Hashira::Coupling::CycleFindings, Hashira::Coupling::SdpViolationFindings,
|
|
12
|
+
Hashira::Coupling::MixedAudienceFindings, Hashira::Coupling::WideEdgeFindings,
|
|
13
|
+
Hashira::Coupling::RollCallFindings
|
|
14
|
+
].freeze
|
|
15
|
+
|
|
16
|
+
def initialize(project, trees, packaging:)
|
|
17
|
+
@project = project
|
|
18
|
+
@graph = Hashira::Coupling::Graph.new(project, trees, census(trees, packaging))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
attr_reader :graph
|
|
22
|
+
|
|
23
|
+
def findings = RULES.flat_map { it.new(@project, @graph).list }
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def census(trees, packaging) = Hashira::Coupling::Census.new(@project, trees, packaging:)
|
|
28
|
+
end
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Coupling::RollCall
|
|
4
|
+
MIN_WORDS = 3
|
|
5
|
+
|
|
6
|
+
MIN_FILES = 3
|
|
7
|
+
|
|
8
|
+
MIN_PACKAGES = 2
|
|
9
|
+
|
|
10
|
+
Roll = Data.define(:words, :files, :packages)
|
|
11
|
+
|
|
12
|
+
def initialize(lists, homes)
|
|
13
|
+
@lists = lists
|
|
14
|
+
@homes = homes
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def rolls = maximal(candidates.filter_map { entry(it) }).sort_by(&:words)
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def files = @files ||= @lists.keys.sort
|
|
22
|
+
|
|
23
|
+
def candidates
|
|
24
|
+
files.combination(2).map { |one, two| @lists[one] & @lists[two] }.select { it.size >= MIN_WORDS }.uniq
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def entry(words)
|
|
28
|
+
holders = files.select { @lists[it] >= words }
|
|
29
|
+
spread = holders.map { @homes[it] }.uniq.sort
|
|
30
|
+
return unless holders.size >= MIN_FILES && spread.size >= MIN_PACKAGES
|
|
31
|
+
Roll.new(words: words.sort, files: holders, packages: spread)
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
def maximal(rolls)
|
|
35
|
+
rolls.reject { |roll| swallowed?(roll, rolls) }
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
def swallowed?(roll, rolls) = rolls.any? { swallows?(it, roll) }
|
|
39
|
+
|
|
40
|
+
def swallows?(big, small)
|
|
41
|
+
mine = big.words
|
|
42
|
+
theirs = small.words
|
|
43
|
+
mine != theirs && (theirs - mine).empty?
|
|
44
|
+
end
|
|
45
|
+
end
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rule"
|
|
4
|
+
|
|
5
|
+
class Hashira::Coupling::RollCallFindings < Hashira::Coupling::Rule
|
|
6
|
+
KIND = "roll_call"
|
|
7
|
+
|
|
8
|
+
def list = rolls.map { entry(it) }
|
|
9
|
+
|
|
10
|
+
private
|
|
11
|
+
|
|
12
|
+
def rolls = Hashira::Coupling::RollCall.new(lists, homes).rolls
|
|
13
|
+
|
|
14
|
+
def sightings
|
|
15
|
+
@sightings ||=
|
|
16
|
+
graph.trees.filter_map do |file, tree|
|
|
17
|
+
words = Hashira::Coupling::Words.list(tree)
|
|
18
|
+
[project.relative(file), graph.charge(file), words] unless words.empty?
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def lists = sightings.to_h { |file, _home, words| [file, words] }
|
|
23
|
+
|
|
24
|
+
def homes = sightings.to_h { |file, home, _words| [file, home] }
|
|
25
|
+
|
|
26
|
+
def entry(roll)
|
|
27
|
+
words, files, packages = roll.deconstruct
|
|
28
|
+
finding(package: packages.first, digest: words.join(","), evidence: files, detail: { words:, files:, packages: })
|
|
29
|
+
end
|
|
30
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Coupling::Roster
|
|
4
|
+
def initialize(placed)
|
|
5
|
+
@registry = Hashira::Coupling::ConstantRegistry.new
|
|
6
|
+
@types = Hash.new(0)
|
|
7
|
+
counted = Set.new
|
|
8
|
+
placed.each { |definition, package| admit(definition, package, counted) }
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
attr_reader :registry, :types
|
|
12
|
+
|
|
13
|
+
def origins = @registry.origins
|
|
14
|
+
|
|
15
|
+
def packages = @types.keys | @registry.packages
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def admit(definition, package, counted)
|
|
20
|
+
return unless package
|
|
21
|
+
path = definition.path
|
|
22
|
+
@registry.register(path, package)
|
|
23
|
+
@types[package] += 1 if definition.counted? && counted.add?(path)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Coupling::Rule
|
|
4
|
+
def initialize(project, graph)
|
|
5
|
+
@project = project
|
|
6
|
+
@graph = graph
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
private
|
|
10
|
+
|
|
11
|
+
attr_reader :project, :graph
|
|
12
|
+
|
|
13
|
+
def metrics = @metrics ||= graph.metrics
|
|
14
|
+
|
|
15
|
+
def finding(**attributes)
|
|
16
|
+
Hashira::Analysis::Finding.new(kind: self.class::KIND, cycle: nil, **attributes)
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Coupling::Scope
|
|
4
|
+
def initialize(registry, catalog, placement)
|
|
5
|
+
@registry = registry
|
|
6
|
+
@catalog = catalog
|
|
7
|
+
@placement = placement
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def resolve(segments, nesting)
|
|
11
|
+
return if @placement.skip?(segments)
|
|
12
|
+
scoped = nesting.reverse_each.filter_map { descend(it, segments) }.first
|
|
13
|
+
scoped ? qualify(scoped) : @registry.package(@catalog.strip(segments))
|
|
14
|
+
end
|
|
15
|
+
|
|
16
|
+
def pinpoint(segments)
|
|
17
|
+
return if @placement.skip?(segments)
|
|
18
|
+
qualify(descend([], segments))
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def descend(entry, segments)
|
|
24
|
+
segments.length.downto(1).filter_map { @registry.exact(@catalog.strip(entry + segments.first(it))) }.first
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def qualify(found) = (found unless found == Hashira::Coupling::ConstantRegistry::AMBIGUOUS)
|
|
28
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Coupling::SdpCheck
|
|
4
|
+
def initialize(dependencies, metrics)
|
|
5
|
+
@dependencies = dependencies
|
|
6
|
+
@metrics = metrics
|
|
7
|
+
end
|
|
8
|
+
|
|
9
|
+
def violations
|
|
10
|
+
@dependencies.flat_map do |from, tos|
|
|
11
|
+
tos.select { @metrics[it].instability > @metrics[from].instability }.map { [from, it] }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rule"
|
|
4
|
+
|
|
5
|
+
class Hashira::Coupling::SdpViolationFindings < Hashira::Coupling::Rule
|
|
6
|
+
KIND = "sdp_violation"
|
|
7
|
+
|
|
8
|
+
def list
|
|
9
|
+
ranked.map { |from, to| violation(from, to) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def ranked = graph.violations.sort_by { |from, to| instability(from) - instability(to) }
|
|
15
|
+
|
|
16
|
+
def violation(from, to)
|
|
17
|
+
finding(package: from, evidence: graph.evidence(from, to).to_a.first(5), detail: detail(from, to))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def detail(from, to)
|
|
21
|
+
{ from:, to:, from_instability: instability(from), to_instability: instability(to) }
|
|
22
|
+
end
|
|
23
|
+
|
|
24
|
+
def instability(package) = metrics[package].instability
|
|
25
|
+
end
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rule"
|
|
4
|
+
|
|
5
|
+
class Hashira::Coupling::WideEdgeFindings < Hashira::Coupling::Rule
|
|
6
|
+
KIND = "wide_edge"
|
|
7
|
+
|
|
8
|
+
WIDTH = 5
|
|
9
|
+
|
|
10
|
+
def list
|
|
11
|
+
graph.edges.select { wide?(it) }.map { entry(it) }
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def wide?(edge) = graph.constants(edge).size >= WIDTH
|
|
17
|
+
|
|
18
|
+
def entry(edge)
|
|
19
|
+
from, to = edge.deconstruct
|
|
20
|
+
finding(
|
|
21
|
+
package: from, digest: "#{from} -> #{to}", evidence: graph.evidence(from, to).to_a.first(4),
|
|
22
|
+
detail: { from:, to:, constants: graph.constants(edge) }
|
|
23
|
+
)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira::Coupling::Words
|
|
6
|
+
PATTERN = /\A[a-z][a-z_]*\z/
|
|
7
|
+
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def list(tree)
|
|
11
|
+
Hashira::Analysis::NodeWalk.collect(tree).flat_map { entries(it) }.compact.grep(PATTERN).to_set
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def entries(node)
|
|
15
|
+
case node
|
|
16
|
+
when Prism::ArrayNode then node.elements.map { word(it) }
|
|
17
|
+
when Prism::HashNode then keys(node)
|
|
18
|
+
else []
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def keys(node) = node.elements.grep(Prism::AssocNode).map { word(it.key) }
|
|
23
|
+
|
|
24
|
+
def word(node)
|
|
25
|
+
case node
|
|
26
|
+
when Prism::SymbolNode, Prism::StringNode then node.unescaped
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
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
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Diagram::Source
|
|
4
|
+
def initialize(graph, format, io: $stdout)
|
|
5
|
+
@graph = graph
|
|
6
|
+
@format = format
|
|
7
|
+
@io = io
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def print
|
|
11
|
+
edges = @graph.weighted
|
|
12
|
+
@io.puts((@format == :dot ? Hashira::Diagram::Dot.new(edges) : Hashira::Diagram::Mermaid.new(edges)).source)
|
|
13
|
+
0
|
|
14
|
+
end
|
|
15
|
+
end
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Duplication::Clones
|
|
4
|
+
def initialize(project, trees, churn)
|
|
5
|
+
@project = project
|
|
6
|
+
@trees = trees
|
|
7
|
+
@churn = churn
|
|
8
|
+
end
|
|
9
|
+
|
|
10
|
+
def clusters = @clusters ||= Hashira::Duplication::Clusters.new(fragments).sorted
|
|
11
|
+
|
|
12
|
+
def findings = clusters.map { |cluster| Hashira::Duplication::DuplicationFinding.new(cluster, @churn).to_finding }
|
|
13
|
+
|
|
14
|
+
private
|
|
15
|
+
|
|
16
|
+
def fragments = Hashira::Duplication::Harvest.new(@project, @trees).fragments
|
|
17
|
+
end
|
|
@@ -2,25 +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
|
|
14
15
|
|
|
15
|
-
|
|
16
|
+
def identical = sites.select { it.types == canonical.types }
|
|
16
17
|
|
|
17
|
-
|
|
18
|
+
def mass = canonical.mass
|
|
18
19
|
|
|
19
|
-
|
|
20
|
+
def size = sites.size
|
|
20
21
|
|
|
21
|
-
|
|
22
|
+
def masses = sites.map { [it.file, mass] }
|
|
22
23
|
|
|
23
|
-
|
|
24
|
-
|
|
24
|
+
def structural? = others.all? { Variance.new(canonical, it).structural? }
|
|
25
|
+
end
|
|
25
26
|
end
|
|
26
27
|
end
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::Duplication::Clusters
|
|
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
|
|
14
|
+
|
|
15
|
+
def sorted
|
|
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.sort_by { -it.mass }
|
|
19
|
+
end
|
|
20
|
+
|
|
21
|
+
private
|
|
22
|
+
|
|
23
|
+
def chain(group) = group.each_cons(2) { |left, right| @sets.union(left, right) }
|
|
24
|
+
|
|
25
|
+
def sized = built.filter_map { admitted(it) }
|
|
26
|
+
|
|
27
|
+
def admitted(cluster) = [cluster, core(cluster)].compact.find { fits?(it) }
|
|
28
|
+
|
|
29
|
+
def fits?(cluster) = cluster.mass >= floor(cluster)
|
|
30
|
+
|
|
31
|
+
def core(cluster) = Hashira::Duplication::Grouping.new(cluster.identical).cluster
|
|
32
|
+
|
|
33
|
+
def built = @sets.clusters.filter_map { |group| Hashira::Duplication::Grouping.new(group).cluster }
|
|
34
|
+
|
|
35
|
+
def floor(cluster) = base(cluster) + penalty(cluster)
|
|
36
|
+
|
|
37
|
+
def base(cluster) = thin?(cluster) ? NEAR_MASS : BASE_MASS
|
|
38
|
+
|
|
39
|
+
def thin?(cluster) = !uniform?(cluster) || cluster.structural?
|
|
40
|
+
|
|
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
|
|
46
|
+
end
|
|
@@ -1,39 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
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)
|
|
3
|
+
class Hashira::Duplication::Delta
|
|
4
|
+
def initialize(cluster)
|
|
5
|
+
@cluster = cluster
|
|
6
|
+
end
|
|
20
7
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
8
|
+
def kind
|
|
9
|
+
tags = kinds
|
|
10
|
+
tags.empty? ? :identical : label(tags)
|
|
11
|
+
end
|
|
25
12
|
|
|
26
|
-
|
|
27
|
-
|
|
13
|
+
def to_h
|
|
14
|
+
{
|
|
15
|
+
mass: @cluster.mass, sites: @cluster.size, kind:,
|
|
16
|
+
locations: @cluster.sites.sort_by(&:rank).map(&:range)
|
|
17
|
+
}
|
|
18
|
+
end
|
|
28
19
|
|
|
29
|
-
|
|
30
|
-
{ mass: @cluster.mass, sites: @cluster.size, kind:,
|
|
31
|
-
locations: @cluster.sites.sort_by(&:sort_key).map(&:range) }
|
|
32
|
-
end
|
|
20
|
+
private
|
|
33
21
|
|
|
34
|
-
|
|
22
|
+
def label(tags)
|
|
23
|
+
return :structure if tags.include?(:structure)
|
|
24
|
+
tags.size == 1 ? tags.first : :mixed
|
|
25
|
+
end
|
|
35
26
|
|
|
36
|
-
|
|
37
|
-
|
|
27
|
+
def kinds
|
|
28
|
+
@cluster.others.flat_map { |other| Hashira::Duplication::Variance.new(@cluster.canonical, other).kinds }.uniq
|
|
38
29
|
end
|
|
39
30
|
end
|