hashira 0.4.0 → 0.5.1
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 +86 -0
- data/README.md +90 -30
- data/lib/hashira/analysis/finding.rb +2 -2
- data/lib/hashira/analysis/syntax.rb +6 -2
- data/lib/hashira/churn.rb +1 -1
- data/lib/hashira/ci/ratchet.rb +5 -9
- data/lib/hashira/cli/command_line.rb +10 -49
- data/lib/hashira/cli/fail_on.rb +9 -5
- data/lib/hashira/cli/flag.rb +29 -0
- data/lib/hashira/cli/flags.rb +50 -0
- data/lib/hashira/cli/format.rb +13 -0
- data/lib/hashira/cli/options.rb +2 -0
- data/lib/hashira/cli/run.rb +8 -4
- data/lib/hashira/cli/usage.rb +20 -21
- data/lib/hashira/cli.rb +1 -1
- data/lib/hashira/complexity/boolean_run.rb +5 -3
- data/lib/hashira/complexity/cognitive_score.rb +21 -14
- data/lib/hashira/complexity/if_chain.rb +14 -14
- data/lib/hashira/complexity/method_finding.rb +4 -19
- data/lib/hashira/complexity/rescue_scan.rb +9 -9
- data/lib/hashira/complexity/{analyzer.rb → scores.rb} +6 -5
- data/lib/hashira/coupling/audiences.rb +49 -0
- data/lib/hashira/{analysis → coupling}/catalog.rb +3 -3
- data/lib/hashira/{analysis → coupling}/census.rb +17 -8
- data/lib/hashira/{analysis → coupling}/constant_registry.rb +11 -6
- data/lib/hashira/coupling/cycle_findings.rb +31 -0
- data/lib/hashira/{analysis → coupling}/cycle_search.rb +1 -1
- data/lib/hashira/{analysis → coupling}/cycles.rb +2 -2
- data/lib/hashira/{analysis → coupling}/definition.rb +6 -2
- data/lib/hashira/{analysis → coupling}/definitions.rb +5 -2
- data/lib/hashira/{analysis → coupling}/edge.rb +1 -1
- data/lib/hashira/{analysis → coupling}/edge_map.rb +10 -10
- data/lib/hashira/{analysis → coupling}/folder_placement.rb +2 -2
- data/lib/hashira/{analysis → coupling}/folding.rb +4 -2
- data/lib/hashira/{analysis → coupling}/graph.rb +18 -8
- data/lib/hashira/{analysis → coupling}/metric.rb +1 -1
- data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
- data/lib/hashira/{analysis → coupling}/namespace_placement.rb +3 -3
- data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +1 -1
- data/lib/hashira/{analysis → coupling}/naming.rb +2 -2
- data/lib/hashira/{analysis → coupling}/no_folding.rb +1 -1
- data/lib/hashira/{analysis → coupling}/placement.rb +3 -3
- 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/{analysis → coupling}/roster.rb +6 -2
- data/lib/hashira/{analysis → coupling}/rule.rb +1 -1
- data/lib/hashira/{analysis/resolver.rb → coupling/scope.rb} +7 -3
- data/lib/hashira/{analysis → coupling}/sdp_check.rb +1 -1
- 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/{renderer.rb → source.rb} +2 -2
- data/lib/hashira/duplication/{analyzer.rb → clones.rb} +3 -3
- data/lib/hashira/duplication/{clusterer.rb → clusters.rb} +3 -3
- data/lib/hashira/duplication/delta.rb +6 -14
- data/lib/hashira/duplication/duplication_finding.rb +8 -10
- data/lib/hashira/duplication/{extractor.rb → harvest.rb} +1 -1
- data/lib/hashira/duplication/sequence.rb +3 -1
- data/lib/hashira/duplication/similarity.rb +5 -1
- data/lib/hashira/duplication/variance.rb +3 -3
- data/lib/hashira/hotspots/rollup.rb +1 -1
- data/lib/hashira/pipeline.rb +18 -10
- data/lib/hashira/report/complexity_table.rb +1 -1
- data/lib/hashira/report/finding_lines.rb +1 -1
- data/lib/hashira/report/json.rb +5 -3
- data/lib/hashira/report/phrases.rb +98 -0
- data/lib/hashira/report/smell_phrases.rb +68 -0
- data/lib/hashira/smells/census.rb +32 -0
- data/lib/hashira/smells/check.rb +34 -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 +32 -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 +46 -0
- data/lib/hashira/smells/manual_dispatch.rb +16 -0
- data/lib/hashira/smells/module_initialize.rb +11 -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 +31 -0
- data/lib/hashira/smells/report.rb +34 -0
- data/lib/hashira/smells/scope.rb +29 -0
- data/lib/hashira/smells/too_many_instance_variables.rb +26 -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 +68 -30
- metadata +63 -31
- data/lib/hashira/analysis/cycle_findings.rb +0 -38
- data/lib/hashira/analysis/references.rb +0 -40
- data/lib/hashira/analysis/sdp_violation_findings.rb +0 -29
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
4
|
|
|
5
|
-
class Hashira::
|
|
5
|
+
class Hashira::Coupling::Folding
|
|
6
6
|
SUFFIXES = %w[Resource Serializer Policy Decorator].freeze
|
|
7
7
|
|
|
8
8
|
def initialize(definitions, census, suffixes: [])
|
|
@@ -32,10 +32,12 @@ class Hashira::Analysis::Folding
|
|
|
32
32
|
def pair(name, stem) = ([name, stem] if stem && @census.packages.include?(stem))
|
|
33
33
|
|
|
34
34
|
def crop(name)
|
|
35
|
-
suffix = @suffixes.find {
|
|
35
|
+
suffix = @suffixes.find { trims?(name, it) }
|
|
36
36
|
suffix && name.delete_suffix(suffix)
|
|
37
37
|
end
|
|
38
38
|
|
|
39
|
+
def trims?(name, suffix) = name.end_with?(suffix) && name != suffix
|
|
40
|
+
|
|
39
41
|
def singles = @singles ||= lone.group_by(&:name).transform_values { it.find(&:superclass) || it.last }
|
|
40
42
|
|
|
41
43
|
def lone = @definitions.select(&:singular?).reject { anchored.include?(it.name) }
|
|
@@ -1,14 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::
|
|
3
|
+
class Hashira::Coupling::Graph
|
|
4
4
|
def initialize(project, trees, census)
|
|
5
5
|
@census = census
|
|
6
|
-
@
|
|
7
|
-
|
|
8
|
-
|
|
6
|
+
@trees = trees
|
|
7
|
+
@map = Hashira::Coupling::EdgeMap.new(census)
|
|
8
|
+
trees.each { |file, tree| @map.record(project.relative(file), file, tree) }
|
|
9
|
+
@cycles = Hashira::Coupling::Cycles.new(@map.dependencies, self)
|
|
9
10
|
end
|
|
10
11
|
|
|
11
|
-
attr_reader :cycles
|
|
12
|
+
attr_reader :cycles, :trees
|
|
13
|
+
|
|
14
|
+
def charge(file) = @census.charge(file, [])
|
|
12
15
|
|
|
13
16
|
def packages = (@census.packages | @map.dependencies.keys)
|
|
14
17
|
|
|
@@ -21,7 +24,7 @@ class Hashira::Analysis::Graph
|
|
|
21
24
|
def incoming(package) = packages.select { dependencies[it].include?(package) }.sort
|
|
22
25
|
|
|
23
26
|
def edges
|
|
24
|
-
dependencies.sort.flat_map { |from, tos| tos.sort.map { Hashira::
|
|
27
|
+
dependencies.sort.flat_map { |from, tos| tos.sort.map { Hashira::Coupling::Edge.new(from:, to: it) } }
|
|
25
28
|
end
|
|
26
29
|
|
|
27
30
|
def weighted
|
|
@@ -33,8 +36,15 @@ class Hashira::Analysis::Graph
|
|
|
33
36
|
|
|
34
37
|
def evidence(from, to) = @map.evidence[[from, to]]
|
|
35
38
|
|
|
39
|
+
def usage(package) = incoming(package).to_h { [it, @map.usage[[it, package]]] }
|
|
40
|
+
|
|
41
|
+
def constants(edge)
|
|
42
|
+
from, to = edge.deconstruct
|
|
43
|
+
@map.usage[[from, to]].sort
|
|
44
|
+
end
|
|
45
|
+
|
|
36
46
|
def metric(package)
|
|
37
|
-
Hashira::
|
|
47
|
+
Hashira::Coupling::Metric.new(
|
|
38
48
|
types: @census.types[package],
|
|
39
49
|
afferent: incoming(package).size,
|
|
40
50
|
efferent: dependencies[package].size
|
|
@@ -43,7 +53,7 @@ class Hashira::Analysis::Graph
|
|
|
43
53
|
|
|
44
54
|
def metrics = packages.to_h { [it, metric(it)] }
|
|
45
55
|
|
|
46
|
-
def violations = Hashira::
|
|
56
|
+
def violations = Hashira::Coupling::SdpCheck.new(dependencies, metrics).violations
|
|
47
57
|
|
|
48
58
|
def weight(from, to) = evidence(from, to).size
|
|
49
59
|
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "rule"
|
|
4
|
+
|
|
5
|
+
class Hashira::Coupling::MixedAudienceFindings < Hashira::Coupling::Rule
|
|
6
|
+
KIND = "mixed_audience"
|
|
7
|
+
|
|
8
|
+
def list
|
|
9
|
+
graph.packages.sort.filter_map { verdict(it) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def verdict(package)
|
|
15
|
+
audiences = Hashira::Coupling::Audiences.new(graph.usage(package))
|
|
16
|
+
detail(package, audiences.parts) if audiences.split?
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def detail(package, parts)
|
|
20
|
+
finding(
|
|
21
|
+
package:, evidence: parts.flat_map do
|
|
22
|
+
sightings(package, it).first(2)
|
|
23
|
+
end, detail: { parts: parts.map(&:to_h) }
|
|
24
|
+
)
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def sightings(package, part)
|
|
28
|
+
part.users.flat_map { |client| graph.evidence(client, package).to_a.select { mentions?(it, part.constants) } }
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def mentions?(line, constants) = constants.any? { line.end_with?(": #{it}", "::#{it}") }
|
|
32
|
+
end
|
|
@@ -2,7 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
require_relative "placement"
|
|
4
4
|
|
|
5
|
-
class Hashira::
|
|
5
|
+
class Hashira::Coupling::NamespacePlacement < Hashira::Coupling::Placement
|
|
6
6
|
RAILS_BASES =
|
|
7
7
|
%w[ApplicationRecord ApplicationController ApplicationJob ApplicationMailer
|
|
8
8
|
ApplicationHelper ApplicationCable ApplicationResource ApplicationSerializer
|
|
@@ -20,9 +20,9 @@ class Hashira::Analysis::NamespacePlacement < Hashira::Analysis::Placement
|
|
|
20
20
|
|
|
21
21
|
def skip?(segments) = project.rails? && RAILS_BASES.include?(segments.first)
|
|
22
22
|
|
|
23
|
-
def folding(census) = Hashira::
|
|
23
|
+
def folding(census) = Hashira::Coupling::Folding.new(catalog, census, suffixes: suffixes)
|
|
24
24
|
|
|
25
25
|
private
|
|
26
26
|
|
|
27
|
-
def suffixes = project.rails? ? Hashira::
|
|
27
|
+
def suffixes = project.rails? ? Hashira::Coupling::Folding::SUFFIXES : []
|
|
28
28
|
end
|
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::
|
|
3
|
+
class Hashira::Coupling::Naming
|
|
4
4
|
def initialize(definitions)
|
|
5
|
-
@segments = Hashira::
|
|
5
|
+
@segments = Hashira::Coupling::NamespacePrefix.infer(definitions)
|
|
6
6
|
end
|
|
7
7
|
|
|
8
8
|
attr_reader :segments
|
|
@@ -1,10 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::
|
|
3
|
+
class Hashira::Coupling::Placement
|
|
4
4
|
def self.build(packaging, project, catalog)
|
|
5
5
|
{
|
|
6
|
-
folder: Hashira::
|
|
7
|
-
namespace: Hashira::
|
|
6
|
+
folder: Hashira::Coupling::FolderPlacement,
|
|
7
|
+
namespace: Hashira::Coupling::NamespacePlacement
|
|
8
8
|
}.fetch(packaging).new(project, catalog)
|
|
9
9
|
end
|
|
10
10
|
|
|
@@ -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
|
|
@@ -1,9 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::
|
|
3
|
+
class Hashira::Coupling::Roster
|
|
4
4
|
def initialize(placed)
|
|
5
|
-
@registry = Hashira::
|
|
5
|
+
@registry = Hashira::Coupling::ConstantRegistry.new
|
|
6
6
|
@types = Hash.new(0)
|
|
7
|
+
@typed = Set.new
|
|
7
8
|
counted = Set.new
|
|
8
9
|
placed.each { |definition, package| admit(definition, package, counted) }
|
|
9
10
|
end
|
|
@@ -12,6 +13,8 @@ class Hashira::Analysis::Roster
|
|
|
12
13
|
|
|
13
14
|
def origins = @registry.origins
|
|
14
15
|
|
|
16
|
+
def type?(path) = @typed.include?(path)
|
|
17
|
+
|
|
15
18
|
def packages = @types.keys | @registry.packages
|
|
16
19
|
|
|
17
20
|
private
|
|
@@ -20,6 +23,7 @@ class Hashira::Analysis::Roster
|
|
|
20
23
|
return unless package
|
|
21
24
|
path = definition.path
|
|
22
25
|
@registry.register(path, package)
|
|
26
|
+
@typed << path if definition.type?
|
|
23
27
|
@types[package] += 1 if definition.counted? && counted.add?(path)
|
|
24
28
|
end
|
|
25
29
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::
|
|
3
|
+
class Hashira::Coupling::Scope
|
|
4
|
+
CORE = Object.constants.select { Object.const_source_location(it) == [] }.to_set(&:to_s).freeze
|
|
5
|
+
|
|
4
6
|
def initialize(registry, catalog, placement)
|
|
5
7
|
@registry = registry
|
|
6
8
|
@catalog = catalog
|
|
@@ -10,7 +12,7 @@ class Hashira::Analysis::Resolver
|
|
|
10
12
|
def resolve(segments, nesting)
|
|
11
13
|
return if @placement.skip?(segments)
|
|
12
14
|
scoped = nesting.reverse_each.filter_map { descend(it, segments) }.first
|
|
13
|
-
scoped ? qualify(scoped) :
|
|
15
|
+
scoped ? qualify(scoped) : outward(@catalog.strip(segments))
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def pinpoint(segments)
|
|
@@ -20,9 +22,11 @@ class Hashira::Analysis::Resolver
|
|
|
20
22
|
|
|
21
23
|
private
|
|
22
24
|
|
|
25
|
+
def outward(path) = CORE.include?(path.first) ? @registry.rooted(path) : @registry.package(path)
|
|
26
|
+
|
|
23
27
|
def descend(entry, segments)
|
|
24
28
|
segments.length.downto(1).filter_map { @registry.exact(@catalog.strip(entry + segments.first(it))) }.first
|
|
25
29
|
end
|
|
26
30
|
|
|
27
|
-
def qualify(found) = (found unless found == Hashira::
|
|
31
|
+
def qualify(found) = (found unless found == Hashira::Coupling::ConstantRegistry::AMBIGUOUS)
|
|
28
32
|
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
|
|
@@ -1,13 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::Diagram::
|
|
3
|
+
class Hashira::Diagram::Source
|
|
4
4
|
def initialize(graph, format, io: $stdout)
|
|
5
5
|
@graph = graph
|
|
6
6
|
@format = format
|
|
7
7
|
@io = io
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def
|
|
10
|
+
def print
|
|
11
11
|
edges = @graph.weighted
|
|
12
12
|
@io.puts((@format == :dot ? Hashira::Diagram::Dot.new(edges) : Hashira::Diagram::Mermaid.new(edges)).source)
|
|
13
13
|
0
|
|
@@ -1,17 +1,17 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::Duplication::
|
|
3
|
+
class Hashira::Duplication::Clones
|
|
4
4
|
def initialize(project, trees, churn)
|
|
5
5
|
@project = project
|
|
6
6
|
@trees = trees
|
|
7
7
|
@churn = churn
|
|
8
8
|
end
|
|
9
9
|
|
|
10
|
-
def clusters = @clusters ||= Hashira::Duplication::
|
|
10
|
+
def clusters = @clusters ||= Hashira::Duplication::Clusters.new(fragments).sorted
|
|
11
11
|
|
|
12
12
|
def findings = clusters.map { |cluster| Hashira::Duplication::DuplicationFinding.new(cluster, @churn).to_finding }
|
|
13
13
|
|
|
14
14
|
private
|
|
15
15
|
|
|
16
|
-
def fragments = Hashira::Duplication::
|
|
16
|
+
def fragments = Hashira::Duplication::Harvest.new(@project, @trees).fragments
|
|
17
17
|
end
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
-
class Hashira::Duplication::
|
|
3
|
+
class Hashira::Duplication::Clusters
|
|
4
4
|
PREFILTER = 12
|
|
5
5
|
BASE_MASS = 16
|
|
6
6
|
NEAR_MASS = 40
|
|
@@ -12,10 +12,10 @@ class Hashira::Duplication::Clusterer
|
|
|
12
12
|
@sets = Hashira::Duplication::UnionFind.new
|
|
13
13
|
end
|
|
14
14
|
|
|
15
|
-
def
|
|
15
|
+
def sorted
|
|
16
16
|
@fragments.group_by(&:types).each_value { |group| chain(group) }
|
|
17
17
|
Hashira::Duplication::NearMiss.new(@fragments).pairs.each { |left, right| @sets.union(left, right) }
|
|
18
|
-
Hashira::Duplication::Maximal.new(sized).reduced
|
|
18
|
+
Hashira::Duplication::Maximal.new(sized).reduced.sort_by { -it.mass }
|
|
19
19
|
end
|
|
20
20
|
|
|
21
21
|
private
|
|
@@ -1,26 +1,13 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
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
4
|
def initialize(cluster)
|
|
14
5
|
@cluster = cluster
|
|
15
6
|
end
|
|
16
7
|
|
|
17
|
-
def summary = ADVICE.fetch(kind)
|
|
18
|
-
|
|
19
8
|
def kind
|
|
20
9
|
tags = kinds
|
|
21
|
-
|
|
22
|
-
return :structure if tags.include?(:structure)
|
|
23
|
-
tags.size == 1 ? tags.first : :mixed
|
|
10
|
+
tags.empty? ? :identical : label(tags)
|
|
24
11
|
end
|
|
25
12
|
|
|
26
13
|
def to_h
|
|
@@ -32,6 +19,11 @@ class Hashira::Duplication::Delta
|
|
|
32
19
|
|
|
33
20
|
private
|
|
34
21
|
|
|
22
|
+
def label(tags)
|
|
23
|
+
return :structure if tags.include?(:structure)
|
|
24
|
+
tags.size == 1 ? tags.first : :mixed
|
|
25
|
+
end
|
|
26
|
+
|
|
35
27
|
def kinds
|
|
36
28
|
@cluster.others.flat_map { |other| Hashira::Duplication::Variance.new(@cluster.canonical, other).kinds }.uniq
|
|
37
29
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Duplication::DuplicationFinding
|
|
4
|
+
KIND = "duplication"
|
|
5
|
+
|
|
4
6
|
def initialize(cluster, churn)
|
|
5
7
|
@cluster = cluster
|
|
6
8
|
@churn = churn
|
|
@@ -8,21 +10,17 @@ class Hashira::Duplication::DuplicationFinding
|
|
|
8
10
|
|
|
9
11
|
def to_finding
|
|
10
12
|
site = @cluster.canonical
|
|
11
|
-
Hashira::Analysis::Finding.new(
|
|
12
|
-
kind: "duplication", package: site.location, digest: site.digest,
|
|
13
|
-
cycle: nil, message:, evidence:
|
|
14
|
-
)
|
|
13
|
+
Hashira::Analysis::Finding.new(kind: KIND, package: site.location, digest: site.digest, detail:, evidence:)
|
|
15
14
|
end
|
|
16
15
|
|
|
17
16
|
private
|
|
18
17
|
|
|
19
|
-
def
|
|
20
|
-
|
|
18
|
+
def detail
|
|
19
|
+
{
|
|
20
|
+
size: @cluster.size, mass: @cluster.mass,
|
|
21
|
+
kind: Hashira::Duplication::Delta.new(@cluster).kind, hot: @churn.hot?(@cluster.sites)
|
|
22
|
+
}
|
|
21
23
|
end
|
|
22
24
|
|
|
23
25
|
def evidence = @cluster.sites.sort_by(&:rank).map(&:range)
|
|
24
|
-
|
|
25
|
-
def note
|
|
26
|
-
@churn.hot?(@cluster.sites) ? " Both sites change often — fix one, miss the other." : ""
|
|
27
|
-
end
|
|
28
26
|
end
|
|
@@ -26,7 +26,9 @@ class Hashira::Duplication::Sequence
|
|
|
26
26
|
|
|
27
27
|
def lengths(segment) = MIN_STATEMENTS..[segment.size, MAX_STATEMENTS].min
|
|
28
28
|
|
|
29
|
-
def slide(segment, length) = (
|
|
29
|
+
def slide(segment, length) = spans(segment, length).map { fragment(segment[it, length]) }
|
|
30
|
+
|
|
31
|
+
def spans(segment, length) = 0..(segment.size - length)
|
|
30
32
|
|
|
31
33
|
def fragment(roots) = Hashira::Duplication::Fragment.new(@file, roots)
|
|
32
34
|
end
|