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.
Files changed (140) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +155 -0
  3. data/README.md +127 -25
  4. data/lib/hashira/analysis/finding.rb +7 -6
  5. data/lib/hashira/analysis/node_walk.rb +3 -3
  6. data/lib/hashira/analysis/syntax.rb +21 -6
  7. data/lib/hashira/analysis/type_walk.rb +7 -9
  8. data/lib/hashira/churn.rb +11 -13
  9. data/lib/hashira/ci/accepted.rb +27 -31
  10. data/lib/hashira/ci/baseline.rb +22 -24
  11. data/lib/hashira/ci/diff.rb +8 -7
  12. data/lib/hashira/ci/edge_diff_report.rb +13 -17
  13. data/lib/hashira/ci/finding_diff_report.rb +14 -18
  14. data/lib/hashira/ci/gate.rb +19 -23
  15. data/lib/hashira/ci/improvement.rb +9 -14
  16. data/lib/hashira/ci/ratchet.rb +32 -27
  17. data/lib/hashira/ci/ratchet_report.rb +26 -32
  18. data/lib/hashira/cli/arguments.rb +23 -0
  19. data/lib/hashira/cli/command_line.rb +55 -71
  20. data/lib/hashira/cli/fail_on.rb +24 -19
  21. data/lib/hashira/cli/options.rb +9 -4
  22. data/lib/hashira/cli/package_by.rb +14 -0
  23. data/lib/hashira/cli/run.rb +33 -27
  24. data/lib/hashira/cli/skip.rb +14 -20
  25. data/lib/hashira/cli/usage.rb +44 -41
  26. data/lib/hashira/cli.rb +17 -19
  27. data/lib/hashira/complexity/boolean_run.rb +13 -15
  28. data/lib/hashira/complexity/cognitive_score.rb +68 -66
  29. data/lib/hashira/complexity/if_chain.rb +33 -38
  30. data/lib/hashira/complexity/method_finding.rb +20 -42
  31. data/lib/hashira/complexity/method_score.rb +9 -7
  32. data/lib/hashira/complexity/rescue_scan.rb +16 -21
  33. data/lib/hashira/complexity/rollup.rb +13 -15
  34. data/lib/hashira/complexity/scores.rb +44 -0
  35. data/lib/hashira/coupling/audiences.rb +49 -0
  36. data/lib/hashira/coupling/catalog.rb +27 -0
  37. data/lib/hashira/coupling/census.rb +55 -0
  38. data/lib/hashira/coupling/constant_registry.rb +47 -0
  39. data/lib/hashira/coupling/cycle_findings.rb +31 -0
  40. data/lib/hashira/coupling/cycle_search.rb +37 -0
  41. data/lib/hashira/coupling/cycles.rb +14 -0
  42. data/lib/hashira/coupling/definition.rb +22 -0
  43. data/lib/hashira/coupling/definitions.rb +31 -0
  44. data/lib/hashira/coupling/edge.rb +10 -0
  45. data/lib/hashira/coupling/edge_map.rb +33 -0
  46. data/lib/hashira/coupling/folder_placement.rb +17 -0
  47. data/lib/hashira/coupling/folding.rb +56 -0
  48. data/lib/hashira/coupling/graph.rb +63 -0
  49. data/lib/hashira/coupling/metric.rb +15 -0
  50. data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
  51. data/lib/hashira/coupling/namespace_placement.rb +28 -0
  52. data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +11 -11
  53. data/lib/hashira/coupling/naming.rb +14 -0
  54. data/lib/hashira/coupling/no_folding.rb +13 -0
  55. data/lib/hashira/coupling/placement.rb +19 -0
  56. data/lib/hashira/coupling/references.rb +55 -0
  57. data/lib/hashira/coupling/report.rb +28 -0
  58. data/lib/hashira/coupling/roll_call.rb +45 -0
  59. data/lib/hashira/coupling/roll_call_findings.rb +30 -0
  60. data/lib/hashira/coupling/roster.rb +25 -0
  61. data/lib/hashira/coupling/rule.rb +18 -0
  62. data/lib/hashira/coupling/scope.rb +28 -0
  63. data/lib/hashira/coupling/sdp_check.rb +14 -0
  64. data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
  65. data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
  66. data/lib/hashira/coupling/words.rb +29 -0
  67. data/lib/hashira/diagram/dot.rb +8 -11
  68. data/lib/hashira/diagram/mermaid.rb +11 -16
  69. data/lib/hashira/diagram/source.rb +15 -0
  70. data/lib/hashira/duplication/clones.rb +17 -0
  71. data/lib/hashira/duplication/cluster.rb +14 -13
  72. data/lib/hashira/duplication/clusters.rb +46 -0
  73. data/lib/hashira/duplication/delta.rb +21 -30
  74. data/lib/hashira/duplication/duplication_finding.rb +17 -23
  75. data/lib/hashira/duplication/fragment.rb +19 -23
  76. data/lib/hashira/duplication/grouping.rb +12 -16
  77. data/lib/hashira/duplication/harvest.rb +27 -0
  78. data/lib/hashira/duplication/index.rb +20 -24
  79. data/lib/hashira/duplication/maximal.rb +11 -15
  80. data/lib/hashira/duplication/near_miss.rb +19 -22
  81. data/lib/hashira/duplication/sequence.rb +20 -22
  82. data/lib/hashira/duplication/similarity.rb +31 -34
  83. data/lib/hashira/duplication/union_find.rb +10 -14
  84. data/lib/hashira/duplication/variance.rb +35 -43
  85. data/lib/hashira/error.rb +4 -2
  86. data/lib/hashira/hotspots/file_cost.rb +8 -7
  87. data/lib/hashira/hotspots/rollup.rb +19 -21
  88. data/lib/hashira/pipeline.rb +41 -31
  89. data/lib/hashira/project.rb +50 -49
  90. data/lib/hashira/report/complexity_table.rb +27 -31
  91. data/lib/hashira/report/dependency_map.rb +17 -22
  92. data/lib/hashira/report/finding_lines.rb +15 -19
  93. data/lib/hashira/report/graph_payload.rb +14 -20
  94. data/lib/hashira/report/hotspot_table.rb +32 -37
  95. data/lib/hashira/report/json.rb +32 -32
  96. data/lib/hashira/report/metrics_table.rb +46 -33
  97. data/lib/hashira/report/phrases.rb +98 -0
  98. data/lib/hashira/report/smell_phrases.rb +68 -0
  99. data/lib/hashira/report/text.rb +59 -52
  100. data/lib/hashira/smells/census.rb +32 -0
  101. data/lib/hashira/smells/check.rb +30 -0
  102. data/lib/hashira/smells/conditions.rb +44 -0
  103. data/lib/hashira/smells/contexts.rb +64 -0
  104. data/lib/hashira/smells/control_parameter.rb +22 -0
  105. data/lib/hashira/smells/data_clump.rb +30 -0
  106. data/lib/hashira/smells/duplicate_method_call.rb +54 -0
  107. data/lib/hashira/smells/feature_envy.rb +17 -0
  108. data/lib/hashira/smells/instance_variable_assumption.rb +44 -0
  109. data/lib/hashira/smells/manual_dispatch.rb +16 -0
  110. data/lib/hashira/smells/module_initialize.rb +9 -0
  111. data/lib/hashira/smells/nil_check.rb +32 -0
  112. data/lib/hashira/smells/param_check.rb +51 -0
  113. data/lib/hashira/smells/refs.rb +53 -0
  114. data/lib/hashira/smells/repeated_conditional.rb +29 -0
  115. data/lib/hashira/smells/report.rb +31 -0
  116. data/lib/hashira/smells/scope.rb +29 -0
  117. data/lib/hashira/smells/too_many_instance_variables.rb +24 -0
  118. data/lib/hashira/smells/utility_function.rb +13 -0
  119. data/lib/hashira/smells/visibility.rb +64 -0
  120. data/lib/hashira/version.rb +1 -1
  121. data/lib/hashira.rb +131 -60
  122. metadata +62 -20
  123. data/lib/hashira/analysis/census.rb +0 -39
  124. data/lib/hashira/analysis/constant_registry.rb +0 -40
  125. data/lib/hashira/analysis/cycle_findings.rb +0 -33
  126. data/lib/hashira/analysis/cycle_search.rb +0 -44
  127. data/lib/hashira/analysis/definitions.rb +0 -27
  128. data/lib/hashira/analysis/edge.rb +0 -9
  129. data/lib/hashira/analysis/edge_map.rb +0 -36
  130. data/lib/hashira/analysis/graph.rb +0 -56
  131. data/lib/hashira/analysis/metric.rb +0 -14
  132. data/lib/hashira/analysis/references.rb +0 -39
  133. data/lib/hashira/analysis/rule.rb +0 -22
  134. data/lib/hashira/analysis/sdp_check.rb +0 -18
  135. data/lib/hashira/analysis/sdp_violation_findings.rb +0 -27
  136. data/lib/hashira/complexity/analyzer.rb +0 -45
  137. data/lib/hashira/diagram/renderer.rb +0 -20
  138. data/lib/hashira/duplication/analyzer.rb +0 -21
  139. data/lib/hashira/duplication/clusterer.rb +0 -50
  140. data/lib/hashira/duplication/extractor.rb +0 -31
@@ -1,22 +1,20 @@
1
1
  # frozen_string_literal: true
2
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) }
3
+ class Hashira::Complexity::Rollup
4
+ def initialize(scores)
5
+ @scores = scores
6
+ end
11
7
 
12
- private
8
+ def classes = @scores.group_by { owner(it.subject) }.map { |name, group| score(name, group) }
13
9
 
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
10
+ private
18
11
 
19
- def class_name(subject) = subject.split(/[#.]/, 2).first
20
- end
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
@@ -0,0 +1,44 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Complexity::Scores
4
+ THRESHOLD = 10
5
+
6
+ def initialize(project, trees)
7
+ @project = project
8
+ @scores = trees.flat_map { |path, tree| harvest(path, tree) }
9
+ end
10
+
11
+ def ranked = @scores.sort_by { -it.cognitive }
12
+
13
+ def classes = Hashira::Complexity::Rollup.new(@scores).classes.sort_by { -it.cognitive }
14
+
15
+ def findings = flagged.map { Hashira::Complexity::MethodFinding.new(it).to_finding }
16
+
17
+ private
18
+
19
+ def flagged = ranked.select { it.cognitive >= THRESHOLD }
20
+
21
+ def harvest(path, tree)
22
+ rel = @project.relative(path)
23
+ sites(tree).map { |full, node| score(rel, full, node) }
24
+ end
25
+
26
+ def sites(tree)
27
+ found = []
28
+ Hashira::Analysis::TypeWalk.each(tree) do |type_node, full|
29
+ Hashira::Analysis::Syntax.direct(type_node).each { found << [full, it] }
30
+ end
31
+ found
32
+ end
33
+
34
+ def score(rel, full, node)
35
+ Hashira::Complexity::MethodScore.new(
36
+ subject: subject(full, node), file: rel, line: node.location.start_line,
37
+ **tallies(Hashira::Complexity::CognitiveScore.new(node))
38
+ )
39
+ end
40
+
41
+ def tallies(score) = { cognitive: score.total, calls: score.calls, increments: score.increments }
42
+
43
+ def subject(full, node) = "#{full.join("::")}#{node.receiver ? "." : "#"}#{node.name}"
44
+ end
@@ -0,0 +1,49 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::Audiences
4
+ MIN = 2
5
+
6
+ Part = Data.define(:users, :constants, :shared)
7
+
8
+ def initialize(usage)
9
+ @usage = usage
10
+ end
11
+
12
+ def split? = parts.size >= 2
13
+
14
+ def parts = @parts ||= [common, *slices].compact.select { it.constants.size >= MIN }
15
+
16
+ private
17
+
18
+ def clients = @clients ||= @usage.keys.sort
19
+
20
+ def shared = @shared ||= tally.select { |_constant, users| users.size * 2 > clients.size }.keys.to_set
21
+
22
+ def tally
23
+ clients.each_with_object(Hash.new { |hash, key| hash[key] = [] }) do |client, seen|
24
+ @usage[client].each { seen[it] << client }
25
+ end
26
+ end
27
+
28
+ def common
29
+ return if shared.empty?
30
+ Part.new(users: clients.select { @usage[it].intersect?(shared) }, constants: shared.sort, shared: true)
31
+ end
32
+
33
+ def slices
34
+ groups.sort_by(&:first).map { |members| Part.new(users: members, constants: pooled(members).sort, shared: false) }
35
+ end
36
+
37
+ def pooled(members) = members.reduce(Set.new) { |pool, client| pool | residue(client) }
38
+
39
+ def residue(client) = @usage[client] - shared
40
+
41
+ def groups
42
+ clients.reduce([]) do |formed, client|
43
+ linked, apart = formed.partition { |members| joined?(members, client) }
44
+ apart + [linked.flatten + [client]]
45
+ end
46
+ end
47
+
48
+ def joined?(members, client) = members.any? { residue(it).intersect?(residue(client)) }
49
+ end
@@ -0,0 +1,27 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::Catalog
4
+ include Enumerable
5
+
6
+ def initialize(definitions)
7
+ @definitions = definitions
8
+ @naming = Hashira::Coupling::Naming.new(definitions)
9
+ @entries = definitions.map { |node, full, folder| entry(node, full, folder) }
10
+ end
11
+
12
+ def each(&) = @entries.each(&)
13
+
14
+ def prefix = @naming.segments
15
+
16
+ def strip(path) = @naming.strip(path)
17
+
18
+ def roots = @definitions.roots
19
+
20
+ def folders = @definitions.packages
21
+
22
+ private
23
+
24
+ def entry(node, full, folder)
25
+ Hashira::Coupling::Definition.new(node:, path: @naming.strip(full), folder:)
26
+ end
27
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::Census
4
+ def initialize(project, trees, packaging: :folder)
5
+ @catalog = Hashira::Coupling::Catalog.new(Hashira::Coupling::Definitions.new(project, trees))
6
+ @placement = Hashira::Coupling::Placement.build(packaging, project, @catalog)
7
+ @folding = Hashira::Coupling::NoFolding
8
+ @roster = tally
9
+ settle
10
+ end
11
+
12
+ def packaging = @placement.mode
13
+
14
+ def types = @roster.types
15
+
16
+ def prefix = @catalog.prefix
17
+
18
+ def origins = @roster.origins
19
+
20
+ def roots = @catalog.roots
21
+
22
+ def packages = @roster.packages | @placement.baseline
23
+
24
+ def folds = @folding.disclosed
25
+
26
+ def strip(segments) = @catalog.strip(segments)
27
+
28
+ def holder(segments)
29
+ path = strip(segments)
30
+ (path.length.downto(1).map { path.first(it) }.find { type?(it) } || path).join("::")
31
+ end
32
+
33
+ def resolve(segments, nesting = []) = scope.resolve(segments, nesting)
34
+
35
+ def pinpoint(segments) = scope.pinpoint(segments)
36
+
37
+ def charge(file, nesting) = translate(@placement.charge(file, nesting))
38
+
39
+ private
40
+
41
+ def type?(segments) = @roster.origins.key?(segments.join("::"))
42
+
43
+ def tally
44
+ Hashira::Coupling::Roster.new(@placement.placed.map { |definition, package| [definition, translate(package)] })
45
+ end
46
+
47
+ def settle
48
+ @folding = @placement.folding(self)
49
+ @roster = tally unless @folding.map.empty?
50
+ end
51
+
52
+ def translate(package) = @folding.map.fetch(package, package)
53
+
54
+ def scope = Hashira::Coupling::Scope.new(@roster.registry, @catalog, @placement)
55
+ end
@@ -0,0 +1,47 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::ConstantRegistry
4
+ AMBIGUOUS = Object.new.freeze
5
+
6
+ def initialize
7
+ @origins = {}
8
+ @shorthand = {}
9
+ end
10
+
11
+ attr_reader :origins
12
+
13
+ def register(path, package)
14
+ return if path.empty?
15
+ claim(@origins, path, package)
16
+ (1...path.length).each { claim(@shorthand, path.drop(it), package) }
17
+ end
18
+
19
+ def package(path)
20
+ found = anchored(path) || enclosing(path)
21
+ found unless found == AMBIGUOUS
22
+ end
23
+
24
+ def exact(path) = @origins[path.join("::")]
25
+
26
+ def packages = (@origins.values.uniq - [AMBIGUOUS])
27
+
28
+ private
29
+
30
+ def claim(claims, path, package)
31
+ key = path.join("::")
32
+ claims[key] = claims.fetch(key, package) == package ? package : AMBIGUOUS
33
+ end
34
+
35
+ def anchored(path)
36
+ key = path.join("::")
37
+ @origins[key] || @shorthand[key]
38
+ end
39
+
40
+ def enclosing(path)
41
+ prefixes(path).filter_map { @origins[it] }.first
42
+ end
43
+
44
+ def prefixes(path)
45
+ (path.length - 1).downto(1).map { path.first(it).join("::") }
46
+ end
47
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "rule"
4
+
5
+ class Hashira::Coupling::CycleFindings < Hashira::Coupling::Rule
6
+ KIND = "cycle"
7
+
8
+ def list
9
+ loops.map { entry(it) }
10
+ end
11
+
12
+ private
13
+
14
+ def loops
15
+ cycles = graph.cycles
16
+ graph.packages.select { cycles.through?(it) }.sort.map { cycles.path(it) }.uniq { it[..-2].sort }
17
+ end
18
+
19
+ def entry(path)
20
+ finding(package: path.first, cycle: path, evidence: evidence(path), detail: detail(graph.cycles.weakest(path)))
21
+ end
22
+
23
+ def detail(weak)
24
+ from, to = weak
25
+ { weak:, weight: graph.weight(from, to) }
26
+ end
27
+
28
+ def evidence(path)
29
+ path.each_cons(2).flat_map { |from, to| graph.evidence(from, to).to_a.first(2) }
30
+ end
31
+ end
@@ -0,0 +1,37 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::CycleSearch
4
+ def initialize(dependencies, package)
5
+ @dependencies = dependencies
6
+ @package = package
7
+ @predecessor = {}
8
+ @queue = dependencies[package].to_a.each { @predecessor[it] = package }
9
+ end
10
+
11
+ def path
12
+ unwind([@package]) if cycle?
13
+ end
14
+
15
+ private
16
+
17
+ def cycle?
18
+ while (node = @queue.shift)
19
+ return true if node == @package
20
+ visit(node)
21
+ end
22
+ end
23
+
24
+ def visit(node)
25
+ @dependencies[node].each do |neighbor|
26
+ next if @predecessor.key?(neighbor)
27
+ @predecessor[neighbor] = node
28
+ @queue << neighbor
29
+ end
30
+ end
31
+
32
+ def unwind(path)
33
+ first = path.first
34
+ return path if first == @package && path.size > 1
35
+ unwind(path.unshift(@predecessor[first]))
36
+ end
37
+ end
@@ -0,0 +1,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::Cycles
4
+ def initialize(dependencies, graph)
5
+ @dependencies = dependencies
6
+ @graph = graph
7
+ end
8
+
9
+ def through?(package) = !!path(package)
10
+
11
+ def path(package) = Hashira::Coupling::CycleSearch.new(@dependencies, package).path
12
+
13
+ def weakest(trail) = trail.each_cons(2).min_by { |from, to| @graph.weight(from, to) }
14
+ end
@@ -0,0 +1,22 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ module Hashira
6
+ module Coupling
7
+ Definition =
8
+ Data.define(:node, :path, :folder) do
9
+ def name = path.first
10
+
11
+ def nested? = path.length > 1
12
+
13
+ def klass? = node.is_a?(Prism::ClassNode)
14
+
15
+ def singular? = klass? && !nested?
16
+
17
+ def superclass = node.superclass
18
+
19
+ def counted? = klass? || !Hashira::Analysis::Syntax.direct(node).empty?
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,31 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::Definitions
4
+ include Enumerable
5
+
6
+ def initialize(project, trees)
7
+ @project = project
8
+ @trees = trees
9
+ end
10
+
11
+ def each(&)
12
+ @trees.each { |file, tree| scan(file, tree, &) }
13
+ end
14
+
15
+ def packages = @trees.keys.map { @project.package(it) }.uniq
16
+
17
+ def roots
18
+ @roots ||= @trees.each_value.with_object(Set.new) { |tree, set| survey(tree, set) }
19
+ end
20
+
21
+ private
22
+
23
+ def survey(tree, set)
24
+ Hashira::Analysis::TypeWalk.each(tree) { |_node, full| set << full }
25
+ end
26
+
27
+ def scan(file, tree)
28
+ package = @project.package(file)
29
+ Hashira::Analysis::TypeWalk.each(tree, roots: roots) { |node, full| yield(node, full, package) }
30
+ end
31
+ end
@@ -0,0 +1,10 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Coupling
5
+ Edge =
6
+ Data.define(:from, :to) do
7
+ def to_s = "#{from} -> #{to}"
8
+ end
9
+ end
10
+ end
@@ -0,0 +1,33 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::EdgeMap
4
+ def initialize(census)
5
+ @census = census
6
+ @dependencies = sets
7
+ @evidence = sets
8
+ @usage = sets
9
+ end
10
+
11
+ attr_reader :dependencies, :evidence, :usage
12
+
13
+ def record(source, file, tree)
14
+ Hashira::Coupling::References.sightings(tree, @census.roots).each do |segments, line, nesting, home|
15
+ note(@census.charge(file, home), locate(segments, nesting), segments, "#{source}:#{line}")
16
+ end
17
+ end
18
+
19
+ private
20
+
21
+ def sets = Hash.new { |hash, key| hash[key] = Set.new }
22
+
23
+ def locate(segments, nesting)
24
+ nesting ? @census.resolve(segments, nesting) : @census.pinpoint(segments)
25
+ end
26
+
27
+ def note(from, to, segments, site)
28
+ return unless to && to != from
29
+ @dependencies[from] << to
30
+ @evidence[[from, to]] << "#{site}: #{segments.join("::")}"
31
+ @usage[[from, to]] << @census.holder(segments)
32
+ end
33
+ end
@@ -0,0 +1,17 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "placement"
4
+
5
+ class Hashira::Coupling::FolderPlacement < Hashira::Coupling::Placement
6
+ def mode = :folder
7
+
8
+ def placed = catalog.map { [it, it.folder] }
9
+
10
+ def baseline = catalog.folders
11
+
12
+ def charge(file, _nesting) = project.package(file)
13
+
14
+ def skip?(_segments) = false
15
+
16
+ def folding(_census) = Hashira::Coupling::NoFolding
17
+ end
@@ -0,0 +1,56 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Coupling::Folding
6
+ SUFFIXES = %w[Resource Serializer Policy Decorator].freeze
7
+
8
+ def initialize(definitions, census, suffixes: [])
9
+ @definitions = definitions
10
+ @census = census
11
+ @suffixes = suffixes
12
+ end
13
+
14
+ def map = @map ||= links.keys.to_h { [it, settle(it, links)] }.reject { |from, to| from == to }
15
+
16
+ def disclosed
17
+ map.map { |from, to| { from:, to:, via: parents.key?(from) ? "base" : "suffix" } }
18
+ end
19
+
20
+ private
21
+
22
+ def links = @links ||= named.merge(parents)
23
+
24
+ def parents
25
+ @parents ||= singles.select { |_name, one| one.superclass }.to_h { |name, one| [name, target(one)] }.compact
26
+ end
27
+
28
+ def named
29
+ singles.keys.filter_map { |name| pair(name, crop(name)) }.to_h
30
+ end
31
+
32
+ def pair(name, stem) = ([name, stem] if stem && @census.packages.include?(stem))
33
+
34
+ def crop(name)
35
+ suffix = @suffixes.find { trims?(name, it) }
36
+ suffix && name.delete_suffix(suffix)
37
+ end
38
+
39
+ def trims?(name, suffix) = name.end_with?(suffix) && name != suffix
40
+
41
+ def singles = @singles ||= lone.group_by(&:name).transform_values { it.find(&:superclass) || it.last }
42
+
43
+ def lone = @definitions.select(&:singular?).reject { anchored.include?(it.name) }
44
+
45
+ def anchored = @anchored ||= @definitions.select(&:nested?).to_set(&:name)
46
+
47
+ def target(one) = @census.pinpoint(Hashira::Analysis::Syntax.segments(one.superclass))
48
+
49
+ def settle(name, links)
50
+ trail = [name]
51
+ while (target = links[name]) && !trail.include?(target)
52
+ trail << (name = target)
53
+ end
54
+ target ? trail.drop(trail.index(target)).min : name
55
+ end
56
+ end
@@ -0,0 +1,63 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Coupling::Graph
4
+ def initialize(project, trees, census)
5
+ @census = census
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)
10
+ end
11
+
12
+ attr_reader :cycles, :trees
13
+
14
+ def charge(file) = @census.charge(file, [])
15
+
16
+ def packages = (@census.packages | @map.dependencies.keys)
17
+
18
+ def packaging = @census.packaging
19
+
20
+ def folds = @census.folds
21
+
22
+ def outgoing(package) = dependencies[package].to_a.sort
23
+
24
+ def incoming(package) = packages.select { dependencies[it].include?(package) }.sort
25
+
26
+ def edges
27
+ dependencies.sort.flat_map { |from, tos| tos.sort.map { Hashira::Coupling::Edge.new(from:, to: it) } }
28
+ end
29
+
30
+ def weighted
31
+ edges.map do |edge|
32
+ from, to = edge.deconstruct
33
+ [from, to, weight(from, to)]
34
+ end
35
+ end
36
+
37
+ def evidence(from, to) = @map.evidence[[from, to]]
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
+
46
+ def metric(package)
47
+ Hashira::Coupling::Metric.new(
48
+ types: @census.types[package],
49
+ afferent: incoming(package).size,
50
+ efferent: dependencies[package].size
51
+ )
52
+ end
53
+
54
+ def metrics = packages.to_h { [it, metric(it)] }
55
+
56
+ def violations = Hashira::Coupling::SdpCheck.new(dependencies, metrics).violations
57
+
58
+ def weight(from, to) = evidence(from, to).size
59
+
60
+ private
61
+
62
+ def dependencies = @map.dependencies
63
+ end
@@ -0,0 +1,15 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Coupling
5
+ Metric =
6
+ Data.define(:types, :afferent, :efferent) do
7
+ def instability
8
+ total = efferent + afferent
9
+ total.zero? ? 0.0 : efferent.fdiv(total)
10
+ end
11
+
12
+ def to_h = { tc: types, ca: afferent, ce: efferent, i: instability }
13
+ end
14
+ end
15
+ end
@@ -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
@@ -0,0 +1,28 @@
1
+ # frozen_string_literal: true
2
+
3
+ require_relative "placement"
4
+
5
+ class Hashira::Coupling::NamespacePlacement < Hashira::Coupling::Placement
6
+ RAILS_BASES =
7
+ %w[ApplicationRecord ApplicationController ApplicationJob ApplicationMailer
8
+ ApplicationHelper ApplicationCable ApplicationResource ApplicationSerializer
9
+ ApplicationPolicy ApplicationDecorator].freeze
10
+
11
+ def mode = :namespace
12
+
13
+ def placed = catalog.map { [it, it.name] }
14
+
15
+ def baseline = []
16
+
17
+ def charge(_file, nesting)
18
+ nesting.reverse_each.filter_map { catalog.strip(it).first }.first || project.root
19
+ end
20
+
21
+ def skip?(segments) = project.rails? && RAILS_BASES.include?(segments.first)
22
+
23
+ def folding(census) = Hashira::Coupling::Folding.new(catalog, census, suffixes: suffixes)
24
+
25
+ private
26
+
27
+ def suffixes = project.rails? ? Hashira::Coupling::Folding::SUFFIXES : []
28
+ end