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.
Files changed (96) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +86 -0
  3. data/README.md +90 -30
  4. data/lib/hashira/analysis/finding.rb +2 -2
  5. data/lib/hashira/analysis/syntax.rb +6 -2
  6. data/lib/hashira/churn.rb +1 -1
  7. data/lib/hashira/ci/ratchet.rb +5 -9
  8. data/lib/hashira/cli/command_line.rb +10 -49
  9. data/lib/hashira/cli/fail_on.rb +9 -5
  10. data/lib/hashira/cli/flag.rb +29 -0
  11. data/lib/hashira/cli/flags.rb +50 -0
  12. data/lib/hashira/cli/format.rb +13 -0
  13. data/lib/hashira/cli/options.rb +2 -0
  14. data/lib/hashira/cli/run.rb +8 -4
  15. data/lib/hashira/cli/usage.rb +20 -21
  16. data/lib/hashira/cli.rb +1 -1
  17. data/lib/hashira/complexity/boolean_run.rb +5 -3
  18. data/lib/hashira/complexity/cognitive_score.rb +21 -14
  19. data/lib/hashira/complexity/if_chain.rb +14 -14
  20. data/lib/hashira/complexity/method_finding.rb +4 -19
  21. data/lib/hashira/complexity/rescue_scan.rb +9 -9
  22. data/lib/hashira/complexity/{analyzer.rb → scores.rb} +6 -5
  23. data/lib/hashira/coupling/audiences.rb +49 -0
  24. data/lib/hashira/{analysis → coupling}/catalog.rb +3 -3
  25. data/lib/hashira/{analysis → coupling}/census.rb +17 -8
  26. data/lib/hashira/{analysis → coupling}/constant_registry.rb +11 -6
  27. data/lib/hashira/coupling/cycle_findings.rb +31 -0
  28. data/lib/hashira/{analysis → coupling}/cycle_search.rb +1 -1
  29. data/lib/hashira/{analysis → coupling}/cycles.rb +2 -2
  30. data/lib/hashira/{analysis → coupling}/definition.rb +6 -2
  31. data/lib/hashira/{analysis → coupling}/definitions.rb +5 -2
  32. data/lib/hashira/{analysis → coupling}/edge.rb +1 -1
  33. data/lib/hashira/{analysis → coupling}/edge_map.rb +10 -10
  34. data/lib/hashira/{analysis → coupling}/folder_placement.rb +2 -2
  35. data/lib/hashira/{analysis → coupling}/folding.rb +4 -2
  36. data/lib/hashira/{analysis → coupling}/graph.rb +18 -8
  37. data/lib/hashira/{analysis → coupling}/metric.rb +1 -1
  38. data/lib/hashira/coupling/mixed_audience_findings.rb +32 -0
  39. data/lib/hashira/{analysis → coupling}/namespace_placement.rb +3 -3
  40. data/lib/hashira/{analysis → coupling}/namespace_prefix.rb +1 -1
  41. data/lib/hashira/{analysis → coupling}/naming.rb +2 -2
  42. data/lib/hashira/{analysis → coupling}/no_folding.rb +1 -1
  43. data/lib/hashira/{analysis → coupling}/placement.rb +3 -3
  44. data/lib/hashira/coupling/references.rb +55 -0
  45. data/lib/hashira/coupling/report.rb +28 -0
  46. data/lib/hashira/coupling/roll_call.rb +45 -0
  47. data/lib/hashira/coupling/roll_call_findings.rb +30 -0
  48. data/lib/hashira/{analysis → coupling}/roster.rb +6 -2
  49. data/lib/hashira/{analysis → coupling}/rule.rb +1 -1
  50. data/lib/hashira/{analysis/resolver.rb → coupling/scope.rb} +7 -3
  51. data/lib/hashira/{analysis → coupling}/sdp_check.rb +1 -1
  52. data/lib/hashira/coupling/sdp_violation_findings.rb +25 -0
  53. data/lib/hashira/coupling/wide_edge_findings.rb +25 -0
  54. data/lib/hashira/coupling/words.rb +29 -0
  55. data/lib/hashira/diagram/{renderer.rb → source.rb} +2 -2
  56. data/lib/hashira/duplication/{analyzer.rb → clones.rb} +3 -3
  57. data/lib/hashira/duplication/{clusterer.rb → clusters.rb} +3 -3
  58. data/lib/hashira/duplication/delta.rb +6 -14
  59. data/lib/hashira/duplication/duplication_finding.rb +8 -10
  60. data/lib/hashira/duplication/{extractor.rb → harvest.rb} +1 -1
  61. data/lib/hashira/duplication/sequence.rb +3 -1
  62. data/lib/hashira/duplication/similarity.rb +5 -1
  63. data/lib/hashira/duplication/variance.rb +3 -3
  64. data/lib/hashira/hotspots/rollup.rb +1 -1
  65. data/lib/hashira/pipeline.rb +18 -10
  66. data/lib/hashira/report/complexity_table.rb +1 -1
  67. data/lib/hashira/report/finding_lines.rb +1 -1
  68. data/lib/hashira/report/json.rb +5 -3
  69. data/lib/hashira/report/phrases.rb +98 -0
  70. data/lib/hashira/report/smell_phrases.rb +68 -0
  71. data/lib/hashira/smells/census.rb +32 -0
  72. data/lib/hashira/smells/check.rb +34 -0
  73. data/lib/hashira/smells/conditions.rb +44 -0
  74. data/lib/hashira/smells/contexts.rb +64 -0
  75. data/lib/hashira/smells/control_parameter.rb +22 -0
  76. data/lib/hashira/smells/data_clump.rb +32 -0
  77. data/lib/hashira/smells/duplicate_method_call.rb +54 -0
  78. data/lib/hashira/smells/feature_envy.rb +17 -0
  79. data/lib/hashira/smells/instance_variable_assumption.rb +46 -0
  80. data/lib/hashira/smells/manual_dispatch.rb +16 -0
  81. data/lib/hashira/smells/module_initialize.rb +11 -0
  82. data/lib/hashira/smells/nil_check.rb +32 -0
  83. data/lib/hashira/smells/param_check.rb +51 -0
  84. data/lib/hashira/smells/refs.rb +53 -0
  85. data/lib/hashira/smells/repeated_conditional.rb +31 -0
  86. data/lib/hashira/smells/report.rb +34 -0
  87. data/lib/hashira/smells/scope.rb +29 -0
  88. data/lib/hashira/smells/too_many_instance_variables.rb +26 -0
  89. data/lib/hashira/smells/utility_function.rb +13 -0
  90. data/lib/hashira/smells/visibility.rb +64 -0
  91. data/lib/hashira/version.rb +1 -1
  92. data/lib/hashira.rb +68 -30
  93. metadata +63 -31
  94. data/lib/hashira/analysis/cycle_findings.rb +0 -38
  95. data/lib/hashira/analysis/references.rb +0 -40
  96. data/lib/hashira/analysis/sdp_violation_findings.rb +0 -29
@@ -1,43 +1,42 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "flags"
4
+
3
5
  module Hashira::CLI::Usage
4
- TEXT = <<~HELP
6
+ PAGES = %i[help version].freeze
7
+
8
+ HEADER = <<~TEXT
5
9
  Usage: hashira [DIRECTORY ...] [options]
6
10
 
7
- Coupling, cognitive-complexity, and duplication metrics for Ruby, via
8
- Prism, rolled up per file as a ranked list of hotspots. With no
9
- directory, auto-detects lib/<gem>.
11
+ Coupling, cognitive-complexity, duplication, and code-smell metrics
12
+ for Ruby, via Prism, rolled up per file as a ranked list of hotspots.
13
+ With no directory, auto-detects lib/<gem>.
10
14
 
11
15
  Options:
12
- --format FORMAT text (default), json, dot, or mermaid
13
- --json shorthand for --format json
14
- --fail-on KINDS exit 1 if findings exist; comma-separated
15
- kinds: cycles, sdp, complexity, duplication
16
- --skip ANALYZERS drop an analyzer; comma-separated: coupling, complexity, duplication
17
- --package-by WHAT group coupling by: auto, folder, or namespace
18
- (top-level constant). Default auto: namespace for
19
- Rails apps (config/application.rb in or beside the
20
- analyzed directory), folder otherwise
21
- --ratchet fail when edges or findings appear that the
22
- baseline lacks
23
- --update-baseline record the current edges and findings
24
- --baseline PATH baseline file (default: hashira_baseline.json)
25
- -h, --help print this help
26
- --version print the version
16
+ TEXT
17
+
18
+ FOOTER = <<~TEXT
27
19
 
28
20
  Findings accepted by design can be recorded in the baseline as
29
21
  "accepted": [{"kind": "...", "package": "...", "reason": "..."}]
30
22
  — they leave reports and gates, keeping a one-line reminder each.
31
23
  Clones are named by "digest" instead of "package", so an acceptance
32
24
  survives the lines above it moving. Read digests from --json.
33
- HELP
25
+ TEXT
34
26
 
35
27
  module_function
36
28
 
37
- def help = emit(TEXT)
29
+ def help = emit(HEADER + options + FOOTER)
38
30
 
39
31
  def version = emit("hashira #{Hashira::VERSION}")
40
32
 
33
+ def options = Hashira::CLI::FLAGS.map { rows(it) }.join
34
+
35
+ def rows(flag)
36
+ first, *rest = flag.text
37
+ [" #{flag.label.ljust(21)}#{first}\n", *rest.map { "#{" " * 23}#{it}\n" }].join
38
+ end
39
+
41
40
  def emit(output)
42
41
  puts(output)
43
42
  0
data/lib/hashira/cli.rb CHANGED
@@ -8,7 +8,7 @@ class Hashira::CLI
8
8
  failure(error)
9
9
  end
10
10
 
11
- def self.usage?(options) = %i[help version].include?(options.mode)
11
+ def self.usage?(options) = Usage::PAGES.include?(options.mode)
12
12
 
13
13
  def self.failure(error)
14
14
  warn("hashira: #{error.message}")
@@ -5,14 +5,16 @@ class Hashira::Complexity::BooleanRun
5
5
  @scorer = scorer
6
6
  end
7
7
 
8
- def apply(node, nesting)
8
+ def apply(node)
9
9
  @scorer.add(node, 1, "boolean")
10
- operands(node).each { @scorer.visit(it, nesting) }
10
+ operands(node).each { @scorer.visit(it) }
11
11
  end
12
12
 
13
13
  private
14
14
 
15
15
  def operands(node)
16
- node.compact_child_nodes.flat_map { it.instance_of?(node.class) ? operands(it) : [it] }
16
+ node.compact_child_nodes.flat_map { kin?(it, node) ? operands(it) : [it] }
17
17
  end
18
+
19
+ def kin?(child, node) = child.instance_of?(node.class)
18
20
  end
@@ -27,41 +27,48 @@ class Hashira::Complexity::CognitiveScore
27
27
  def initialize(def_node)
28
28
  @increments = []
29
29
  @calls = 0
30
- visit(def_node.body, 0)
30
+ @nesting = 0
31
+ visit(def_node.body)
31
32
  end
32
33
 
33
- attr_reader :increments, :calls
34
+ attr_reader :increments, :calls, :nesting
34
35
 
35
36
  def total = @increments.sum(&:cost)
36
37
 
37
- def visit(node, nesting)
38
+ def visit(node)
38
39
  return unless node
39
- __send__(HANDLERS.fetch(node.class, :descend), node, nesting)
40
+ __send__(HANDLERS.fetch(node.class, :descend), node)
40
41
  end
41
42
 
42
43
  def add(node, cost, label)
43
44
  @increments << Hashira::Complexity::Increment.new(line: node.location.start_line, cost:, label:)
44
45
  end
45
46
 
47
+ def deeper
48
+ @nesting += 1
49
+ yield
50
+ @nesting -= 1
51
+ end
52
+
46
53
  private
47
54
 
48
- def descend(node, nesting) = node.compact_child_nodes.each { visit(it, nesting) }
55
+ def descend(node) = node.compact_child_nodes.each { visit(it) }
49
56
 
50
- def on_call(node, nesting)
57
+ def on_call(node)
51
58
  @calls += 1
52
- descend(node, nesting)
59
+ descend(node)
53
60
  end
54
61
 
55
- def on_block(node, nesting) = node.compact_child_nodes.each { visit(it, nesting + 1) }
62
+ def on_block(node) = deeper { descend(node) }
56
63
 
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) }
64
+ def on_nester(node)
65
+ add(node, 1 + @nesting, LABELS.fetch(node.class))
66
+ deeper { descend(node) }
60
67
  end
61
68
 
62
- def on_if(node, nesting) = Hashira::Complexity::IfChain.new(self).apply(node, nesting)
69
+ def on_if(node) = Hashira::Complexity::IfChain.new(self).apply(node)
63
70
 
64
- def on_begin(node, nesting) = Hashira::Complexity::RescueScan.new(self).apply(node, nesting)
71
+ def on_begin(node) = Hashira::Complexity::RescueScan.new(self).apply(node)
65
72
 
66
- def on_boolean(node, nesting) = Hashira::Complexity::BooleanRun.new(self).apply(node, nesting)
73
+ def on_boolean(node) = Hashira::Complexity::BooleanRun.new(self).apply(node)
67
74
  end
@@ -7,34 +7,34 @@ class Hashira::Complexity::IfChain
7
7
  @scorer = scorer
8
8
  end
9
9
 
10
- def apply(node, nesting)
11
- return ternary(node, nesting) unless node.if_keyword
12
- branch(node, 1 + nesting, nesting, "if")
10
+ def apply(node)
11
+ return ternary(node) unless node.if_keyword
12
+ branch(node, 1 + @scorer.nesting, "if")
13
13
  end
14
14
 
15
15
  private
16
16
 
17
- def branch(node, cost, nesting, label)
17
+ def branch(node, cost, label)
18
18
  @scorer.add(node, cost, label)
19
- @scorer.visit(node.predicate, nesting)
20
- @scorer.visit(node.statements, nesting + 1)
21
- tail(node.subsequent, nesting)
19
+ @scorer.visit(node.predicate)
20
+ @scorer.deeper { @scorer.visit(node.statements) }
21
+ tail(node.subsequent)
22
22
  end
23
23
 
24
- def tail(node, nesting)
24
+ def tail(node)
25
25
  case node
26
- when Prism::IfNode then branch(node, 1, nesting, "elsif")
27
- when Prism::ElseNode then otherwise(node, nesting)
26
+ when Prism::IfNode then branch(node, 1, "elsif")
27
+ when Prism::ElseNode then otherwise(node)
28
28
  end
29
29
  end
30
30
 
31
- def otherwise(node, nesting)
31
+ def otherwise(node)
32
32
  @scorer.add(node, 1, "else")
33
- @scorer.visit(node.statements, nesting + 1)
33
+ @scorer.deeper { @scorer.visit(node.statements) }
34
34
  end
35
35
 
36
- def ternary(node, nesting)
36
+ def ternary(node)
37
37
  @scorer.add(node, 1, "ternary")
38
- node.compact_child_nodes.each { @scorer.visit(it, nesting) }
38
+ node.compact_child_nodes.each { @scorer.visit(it) }
39
39
  end
40
40
  end
@@ -1,33 +1,20 @@
1
1
  # frozen_string_literal: true
2
2
 
3
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
4
+ KIND = "complexity"
17
5
 
18
6
  def initialize(score)
19
7
  @score = score
20
8
  end
21
9
 
22
10
  def to_finding
23
- Hashira::Analysis::Finding.new(kind: "complexity", package: @score.subject, cycle: nil, message:, evidence:)
11
+ Hashira::Analysis::Finding.new(kind: KIND, package: @score.subject, detail:, evidence:)
24
12
  end
25
13
 
26
14
  private
27
15
 
28
- def message
29
- "#{@score.subject} cognitive #{@score.cognitive}, #{@score.calls} calls " \
30
- "(#{@score.file}:#{@score.line}). #{advice}"
16
+ def detail
17
+ { cognitive: @score.cognitive, calls: @score.calls, site: "#{@score.file}:#{@score.line}", dominant: }
31
18
  end
32
19
 
33
20
  def evidence
@@ -39,8 +26,6 @@ class Hashira::Complexity::MethodFinding
39
26
  "#{label} +#{incs.sum(&:cost)} (line#{"s" if lines.size > 1} #{lines.join(", ")})"
40
27
  end
41
28
 
42
- def advice = ADVICE.fetch(dominant)
43
-
44
29
  def dominant
45
30
  @score.increments.group_by(&:label).transform_values { it.sum(&:cost) }.max_by(&:last).first
46
31
  end
@@ -5,19 +5,19 @@ class Hashira::Complexity::RescueScan
5
5
  @scorer = scorer
6
6
  end
7
7
 
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)
8
+ def apply(node)
9
+ @scorer.visit(node.statements)
10
+ clauses(node.rescue_clause)
11
+ @scorer.visit(node.else_clause)
12
+ @scorer.visit(node.ensure_clause)
13
13
  end
14
14
 
15
15
  private
16
16
 
17
- def clauses(node, nesting)
17
+ def clauses(node)
18
18
  return unless node
19
- @scorer.add(node, 1 + nesting, "rescue")
20
- @scorer.visit(node.statements, nesting + 1)
21
- clauses(node.subsequent, nesting)
19
+ @scorer.add(node, 1 + @scorer.nesting, "rescue")
20
+ @scorer.deeper { @scorer.visit(node.statements) }
21
+ clauses(node.subsequent)
22
22
  end
23
23
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Complexity::Analyzer
3
+ class Hashira::Complexity::Scores
4
4
  THRESHOLD = 10
5
5
 
6
6
  def initialize(project, trees)
@@ -8,7 +8,7 @@ class Hashira::Complexity::Analyzer
8
8
  @scores = trees.flat_map { |path, tree| harvest(path, tree) }
9
9
  end
10
10
 
11
- def methods = @scores.sort_by { -it.cognitive }
11
+ def ranked = @scores.sort_by { -it.cognitive }
12
12
 
13
13
  def classes = Hashira::Complexity::Rollup.new(@scores).classes.sort_by { -it.cognitive }
14
14
 
@@ -16,7 +16,7 @@ class Hashira::Complexity::Analyzer
16
16
 
17
17
  private
18
18
 
19
- def flagged = methods.select { it.cognitive >= THRESHOLD }
19
+ def flagged = ranked.select { it.cognitive >= THRESHOLD }
20
20
 
21
21
  def harvest(path, tree)
22
22
  rel = @project.relative(path)
@@ -32,12 +32,13 @@ class Hashira::Complexity::Analyzer
32
32
  end
33
33
 
34
34
  def score(rel, full, node)
35
- score = Hashira::Complexity::CognitiveScore.new(node)
36
35
  Hashira::Complexity::MethodScore.new(
37
36
  subject: subject(full, node), file: rel, line: node.location.start_line,
38
- cognitive: score.total, calls: score.calls, increments: score.increments
37
+ **tallies(Hashira::Complexity::CognitiveScore.new(node))
39
38
  )
40
39
  end
41
40
 
41
+ def tallies(score) = { cognitive: score.total, calls: score.calls, increments: score.increments }
42
+
42
43
  def subject(full, node) = "#{full.join("::")}#{node.receiver ? "." : "#"}#{node.name}"
43
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
@@ -1,11 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::Catalog
3
+ class Hashira::Coupling::Catalog
4
4
  include Enumerable
5
5
 
6
6
  def initialize(definitions)
7
7
  @definitions = definitions
8
- @naming = Hashira::Analysis::Naming.new(definitions)
8
+ @naming = Hashira::Coupling::Naming.new(definitions)
9
9
  @entries = definitions.map { |node, full, folder| entry(node, full, folder) }
10
10
  end
11
11
 
@@ -22,6 +22,6 @@ class Hashira::Analysis::Catalog
22
22
  private
23
23
 
24
24
  def entry(node, full, folder)
25
- Hashira::Analysis::Definition.new(node:, path: @naming.strip(full), folder:)
25
+ Hashira::Coupling::Definition.new(node:, path: @naming.strip(full), folder:)
26
26
  end
27
27
  end
@@ -1,10 +1,10 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::Census
3
+ class Hashira::Coupling::Census
4
4
  def initialize(project, trees, packaging: :folder)
5
- @catalog = Hashira::Analysis::Catalog.new(Hashira::Analysis::Definitions.new(project, trees))
6
- @placement = Hashira::Analysis::Placement.build(packaging, project, @catalog)
7
- @folding = Hashira::Analysis::NoFolding
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
8
  @roster = tally
9
9
  settle
10
10
  end
@@ -23,16 +23,25 @@ class Hashira::Analysis::Census
23
23
 
24
24
  def folds = @folding.disclosed
25
25
 
26
- def resolve(segments, nesting = []) = resolver.resolve(segments, nesting)
26
+ def strip(segments) = @catalog.strip(segments)
27
27
 
28
- def pinpoint(segments) = resolver.pinpoint(segments)
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)
29
36
 
30
37
  def charge(file, nesting) = translate(@placement.charge(file, nesting))
31
38
 
32
39
  private
33
40
 
41
+ def type?(segments) = @roster.type?(segments)
42
+
34
43
  def tally
35
- Hashira::Analysis::Roster.new(@placement.placed.map { |definition, package| [definition, translate(package)] })
44
+ Hashira::Coupling::Roster.new(@placement.placed.map { |definition, package| [definition, translate(package)] })
36
45
  end
37
46
 
38
47
  def settle
@@ -42,5 +51,5 @@ class Hashira::Analysis::Census
42
51
 
43
52
  def translate(package) = @folding.map.fetch(package, package)
44
53
 
45
- def resolver = Hashira::Analysis::Resolver.new(@roster.registry, @catalog, @placement)
54
+ def scope = Hashira::Coupling::Scope.new(@roster.registry, @catalog, @placement)
46
55
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::ConstantRegistry
3
+ class Hashira::Coupling::ConstantRegistry
4
4
  AMBIGUOUS = Object.new.freeze
5
5
 
6
6
  def initialize
@@ -16,10 +16,9 @@ class Hashira::Analysis::ConstantRegistry
16
16
  (1...path.length).each { claim(@shorthand, path.drop(it), package) }
17
17
  end
18
18
 
19
- def package(path)
20
- found = anchored(path) || enclosing(path)
21
- found unless found == AMBIGUOUS
22
- end
19
+ def package(path) = settle(anchored(path) || enclosing(path))
20
+
21
+ def rooted(path) = settle(@origins[path.join("::")] || enclosing(path))
23
22
 
24
23
  def exact(path) = @origins[path.join("::")]
25
24
 
@@ -32,12 +31,18 @@ class Hashira::Analysis::ConstantRegistry
32
31
  claims[key] = claims.fetch(key, package) == package ? package : AMBIGUOUS
33
32
  end
34
33
 
34
+ def settle(found) = (found unless found == AMBIGUOUS)
35
+
35
36
  def anchored(path)
36
37
  key = path.join("::")
37
38
  @origins[key] || @shorthand[key]
38
39
  end
39
40
 
40
41
  def enclosing(path)
41
- (path.length - 1).downto(1).filter_map { @origins[path.first(it).join("::")] }.first
42
+ prefixes(path).filter_map { @origins[it] }.first
43
+ end
44
+
45
+ def prefixes(path)
46
+ (path.length - 1).downto(1).map { path.first(it).join("::") }
42
47
  end
43
48
  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
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::CycleSearch
3
+ class Hashira::Coupling::CycleSearch
4
4
  def initialize(dependencies, package)
5
5
  @dependencies = dependencies
6
6
  @package = package
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::Cycles
3
+ class Hashira::Coupling::Cycles
4
4
  def initialize(dependencies, graph)
5
5
  @dependencies = dependencies
6
6
  @graph = graph
@@ -8,7 +8,7 @@ class Hashira::Analysis::Cycles
8
8
 
9
9
  def through?(package) = !!path(package)
10
10
 
11
- def path(package) = Hashira::Analysis::CycleSearch.new(@dependencies, package).path
11
+ def path(package) = Hashira::Coupling::CycleSearch.new(@dependencies, package).path
12
12
 
13
13
  def weakest(trail) = trail.each_cons(2).min_by { |from, to| @graph.weight(from, to) }
14
14
  end
@@ -3,7 +3,7 @@
3
3
  require "prism"
4
4
 
5
5
  module Hashira
6
- module Analysis
6
+ module Coupling
7
7
  Definition =
8
8
  Data.define(:node, :path, :folder) do
9
9
  def name = path.first
@@ -16,7 +16,11 @@ module Hashira
16
16
 
17
17
  def superclass = node.superclass
18
18
 
19
- def counted? = klass? || !Hashira::Analysis::Syntax.direct(node).empty?
19
+ def module? = node.is_a?(Prism::ModuleNode)
20
+
21
+ def type? = klass? || module?
22
+
23
+ def counted? = klass? || (module? && !Hashira::Analysis::Syntax.direct(node).empty?)
20
24
  end
21
25
  end
22
26
  end
@@ -1,6 +1,6 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::Definitions
3
+ class Hashira::Coupling::Definitions
4
4
  include Enumerable
5
5
 
6
6
  def initialize(project, trees)
@@ -26,6 +26,9 @@ class Hashira::Analysis::Definitions
26
26
 
27
27
  def scan(file, tree)
28
28
  package = @project.package(file)
29
- Hashira::Analysis::TypeWalk.each(tree, roots: roots) { |node, full| yield(node, full, package) }
29
+ Hashira::Analysis::TypeWalk.each(tree, roots: roots) do |node, full|
30
+ yield(node, full, package)
31
+ Hashira::Analysis::Syntax.constants(node).each { yield(it, full + [it.name.to_s], package) }
32
+ end
30
33
  end
31
34
  end
@@ -1,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashira
4
- module Analysis
4
+ module Coupling
5
5
  Edge =
6
6
  Data.define(:from, :to) do
7
7
  def to_s = "#{from} -> #{to}"
@@ -1,19 +1,18 @@
1
1
  # frozen_string_literal: true
2
2
 
3
- class Hashira::Analysis::EdgeMap
4
- def initialize(project, census)
5
- @project = project
3
+ class Hashira::Coupling::EdgeMap
4
+ def initialize(census)
6
5
  @census = census
7
6
  @dependencies = sets
8
7
  @evidence = sets
8
+ @usage = sets
9
9
  end
10
10
 
11
- attr_reader :dependencies, :evidence
11
+ attr_reader :dependencies, :evidence, :usage
12
12
 
13
- def record(file, tree)
14
- source = @project.relative(file)
15
- Hashira::Analysis::References.sightings(tree, @census.roots).each do |segments, line, nesting, home|
16
- note(@census.charge(file, home), locate(segments, nesting), "#{source}:#{line}: #{segments.join("::")}")
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}")
17
16
  end
18
17
  end
19
18
 
@@ -25,9 +24,10 @@ class Hashira::Analysis::EdgeMap
25
24
  nesting ? @census.resolve(segments, nesting) : @census.pinpoint(segments)
26
25
  end
27
26
 
28
- def note(from, to, sighting)
27
+ def note(from, to, segments, site)
29
28
  return unless to && to != from
30
29
  @dependencies[from] << to
31
- @evidence[[from, to]] << sighting
30
+ @evidence[[from, to]] << "#{site}: #{segments.join("::")}"
31
+ @usage[[from, to]] << @census.holder(segments)
32
32
  end
33
33
  end
@@ -2,7 +2,7 @@
2
2
 
3
3
  require_relative "placement"
4
4
 
5
- class Hashira::Analysis::FolderPlacement < Hashira::Analysis::Placement
5
+ class Hashira::Coupling::FolderPlacement < Hashira::Coupling::Placement
6
6
  def mode = :folder
7
7
 
8
8
  def placed = catalog.map { [it, it.folder] }
@@ -13,5 +13,5 @@ class Hashira::Analysis::FolderPlacement < Hashira::Analysis::Placement
13
13
 
14
14
  def skip?(_segments) = false
15
15
 
16
- def folding(_census) = Hashira::Analysis::NoFolding
16
+ def folding(_census) = Hashira::Coupling::NoFolding
17
17
  end