hashira 0.5.1 → 0.6.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 698031b3fff8e705bf80379be16ee05ecacfa9c137459ab8beb855cee1c624dd
4
- data.tar.gz: c50d6bc7ee142f665c8764d56d7fdf07de381b00529715e37682aed6f0f1856d
3
+ metadata.gz: a0b871ab3e0bf096a2752a48a4c3776d2dbe2ee0cae33b2df8af89649d0952ca
4
+ data.tar.gz: b1058810a8d965e1010044691d1b11571f21b077581f5e435d40b0d263f133b9
5
5
  SHA512:
6
- metadata.gz: 4ce8b38adba57e0d1bdf6343362efb3e205c6455d5634bf3cd07c2b429870423268fc4263215ad3ea767284a7acc906901259acbbec7570d386db7fc0759626b
7
- data.tar.gz: 3670d8b067f2535d745c3785d52087f2aa189909511566e8452818da408e82882077fe428a72b16470640f60bddcdcbd0fbc6109fc412d64652648c20b1bcb9c
6
+ metadata.gz: 24d9aae54c58e39a54799efdf9749f501000162c8ea8f9a38e02523fb6ee3122a98daf6591cfaf7816f3a2cd66f20218c08ff27579bc1b58a269e4ce7bddda92
7
+ data.tar.gz: 158bf3af24e081df3483e3d124833f6943404cfdab6ca1f2f7235115a400cfb1c48f885fb16b290fc529ebc1556c8eb5ed97119f635c9869ae881b44be51c599
data/CHANGELOG.md CHANGED
@@ -5,6 +5,36 @@ All notable changes to this project will be documented in this file.
5
5
  The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
6
6
  and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
7
7
 
8
+ ## [0.6.0] - 2026-08-05
9
+
10
+ ### Changed
11
+
12
+ - **feature_envy now respects ownership.** The classic remedy — move the
13
+ method onto the envied object — assumes the envied class is yours to edit.
14
+ The smell now stays quiet when the method body itself proves otherwise:
15
+ the name is type-guarded only against constants the analyzed code never
16
+ defines (`node.is_a?(Prism::CallNode)`); every call on it is a literal-key
17
+ read (`msg["id"]`, `values_at`, `dig`, `key?` — wire data, not an object);
18
+ it was built from a literal in the method itself (`options = { ... }`); it
19
+ was derived by calling a foreign name or foreign constant
20
+ (`value = node.unescaped`, `app = Rails.application`); it was rescued from
21
+ a foreign or implied error class (`rescue => e`); the method dispatches on
22
+ it through a constant table keyed entirely by foreign classes
23
+ (`TABLE[node.class]`); or the method is a stateless converter whose last
24
+ act is building a typed object. Guards against types the codebase does
25
+ define — including by suffix, and including subclasses of gem classes —
26
+ still flag, as do rescues from error classes the codebase defines and
27
+ tables keyed by owned classes, so anemic-model envy in Rails apps is
28
+ untouched.
29
+
30
+ ### Added
31
+
32
+ - **boundary_sprawl** — the aggregate the suppression above makes room for:
33
+ when 12+ methods across 3+ files each type-guard against the same foreign
34
+ root (`Prism`, `ActiveRecord`, ...), one finding proposes fronting that
35
+ boundary with an adapter. One method inspecting a foreign type is a fact of
36
+ life; a codebase-wide sprawl of them is a missing seam.
37
+
8
38
  ## [0.5.1] - 2026-08-05
9
39
 
10
40
  ### Fixed
data/README.md CHANGED
@@ -294,7 +294,7 @@ it does inside Ruby:
294
294
  ## Code smells
295
295
 
296
296
  RuboCop counts lines and branches inside one method; design smells are about how
297
- objects treat each other, and no line count sees that. hashira ships the eleven
297
+ objects treat each other, and no line count sees that. hashira ships the twelve
298
298
  smells that carry that design signal — the object-relationship kinds, not the
299
299
  naming, size, and style checks a linter already argues about — read from the
300
300
  same parse trees the other analyzers already built:
@@ -310,7 +310,16 @@ Findings (2):
310
310
  What each one catches:
311
311
 
312
312
  - **feature_envy** — a method refers to another object more than to itself; the
313
- behavior probably belongs over there.
313
+ behavior probably belongs over there. Stays quiet when the method's own body
314
+ proves the envied thing is foreign — type-guarded (or table-dispatched) only
315
+ against constants the codebase never defines, read purely through literal
316
+ keys (`msg["id"]`), built from a literal or derived from a foreign call in
317
+ the method itself, rescued from a foreign error class, or consumed by a
318
+ stateless converter that ends by building a typed object — because "move
319
+ the method" needs a destination you own.
320
+ - **boundary_sprawl** — 12+ methods across 3+ files each type-guard against the
321
+ same foreign root (`Prism`, `ActiveRecord`, ...). One method inspecting a
322
+ foreign type is a fact of life; a sprawl of them is a missing adapter.
314
323
  - **utility_function** — a public instance method that touches no instance state;
315
324
  it isn't really a method of this class. Private stateless helpers are fine, and
316
325
  `module_function` modules are exempt — that's what they're for.
@@ -334,7 +343,7 @@ What each one catches:
334
343
  cheapest type there is.
335
344
 
336
345
  Smell findings gate and ratchet like every other kind — `--fail-on smells` covers
337
- all eleven, or name one (`--fail-on feature_envy`); `--skip smells` drops the
346
+ all twelve, or name one (`--fail-on feature_envy`); `--skip smells` drops the
338
347
  analyzer entirely.
339
348
 
340
349
  ## Hotspots
@@ -9,7 +9,9 @@ class Hashira::Pipeline
9
9
 
10
10
  STRUCTURAL = Hashira::Coupling::Report::RULES.map { it::KIND }.freeze
11
11
 
12
- SMELLS = Hashira::Smells::Report::CHECKS.map(&:kind).sort.freeze
12
+ SMELLS = (
13
+ Hashira::Smells::Report::CHECKS.map(&:kind) + [Hashira::Smells::BoundarySprawl::KIND]
14
+ ).sort.freeze
13
15
 
14
16
  def initialize(project, enabled: ANALYZERS, packaging: :auto)
15
17
  @project = project
@@ -19,6 +19,12 @@ module Hashira::Report::Phrases
19
19
  "Name the result in a local variable."
20
20
  end
21
21
 
22
+ def on_boundary_sprawl(finding)
23
+ detail = finding.detail
24
+ "#{detail[:count]} methods across #{detail[:files]} files each pick apart #{finding.package}'s " \
25
+ "internals. Front the boundary with one adapter the rest can lean on."
26
+ end
27
+
22
28
  def on_feature_envy(finding)
23
29
  detail = finding.detail
24
30
  names = detail[:names]
@@ -0,0 +1,43 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Smells::BoundarySprawl
4
+ KIND = "boundary_sprawl"
5
+
6
+ METHOD_FLOOR = 12
7
+
8
+ FILE_FLOOR = 3
9
+
10
+ SHOWN = 4
11
+
12
+ def initialize(subjects, ownership)
13
+ @subjects = subjects
14
+ @ownership = ownership
15
+ end
16
+
17
+ def findings
18
+ reaches.filter_map { |root, contexts| finding(root, contexts) }
19
+ end
20
+
21
+ private
22
+
23
+ def reaches
24
+ @subjects.each_with_object({}) do |subject, map|
25
+ Hashira::Smells::Foreign.new(subject, @ownership).reaches.each { (map[it] ||= []) << subject }
26
+ end
27
+ end
28
+
29
+ def finding(root, contexts)
30
+ files = contexts.map(&:file).uniq
31
+ build(root, contexts, files) if wide?(contexts.size, files.size)
32
+ end
33
+
34
+ def wide?(methods, files) = methods >= METHOD_FLOOR && files >= FILE_FLOOR
35
+
36
+ def build(root, contexts, files)
37
+ Hashira::Analysis::Finding.new(
38
+ kind: KIND, package: root,
39
+ detail: { count: contexts.size, files: files.size },
40
+ evidence: contexts.first(SHOWN).map(&:site)
41
+ )
42
+ end
43
+ end
@@ -6,8 +6,11 @@ class Hashira::Smells::Census
6
6
  def initialize(project, trees)
7
7
  @project = project
8
8
  @trees = trees
9
+ @ownership = Hashira::Smells::Ownership.new(trees.values)
9
10
  end
10
11
 
12
+ attr_reader :ownership
13
+
11
14
  def types = @trees.flat_map { |path, tree| harvest(@project.relative(path), tree) }
12
15
 
13
16
  private
@@ -26,7 +29,7 @@ class Hashira::Smells::Census
26
29
 
27
30
  def defs(name, node, file)
28
31
  Hashira::Smells::Visibility.new(node).entries.map do |def_node, section|
29
- Hashira::Smells::MethodContext.new(owner: name, node: def_node, file:, section:)
32
+ Hashira::Smells::MethodContext.new(owner: name, node: def_node, file:, section:, ownership: @ownership)
30
33
  end
31
34
  end
32
35
  end
@@ -32,7 +32,7 @@ module Hashira
32
32
  end
33
33
 
34
34
  MethodContext =
35
- Data.define(:owner, :node, :file, :section) do
35
+ Data.define(:owner, :node, :file, :section, :ownership) do
36
36
  def subject = "#{owner}#{singleton? ? "." : "#"}#{node.name}"
37
37
 
38
38
  def line = node.location.start_line
@@ -9,7 +9,9 @@ class Hashira::Smells::FeatureEnvy < Hashira::Smells::Check
9
9
 
10
10
  def refs = @refs ||= Hashira::Smells::Refs.new(subject.node)
11
11
 
12
- def envied = @envied ||= refs.envious
12
+ def envied = @envied ||= refs.envious.reject { foreign.dismiss?(it) }
13
+
14
+ def foreign = @foreign ||= Hashira::Smells::Foreign.new(subject, subject.ownership)
13
15
 
14
16
  def detail = { site:, names: envied }
15
17
 
@@ -0,0 +1,131 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Smells::Foreign
6
+ TYPE_TESTS = %i[is_a? kind_of? instance_of?].freeze
7
+
8
+ KEYED_READS = %i[[] fetch values_at dig key?].freeze
9
+
10
+ KEYS = [Prism::StringNode, Prism::SymbolNode].freeze
11
+
12
+ LITERALS = [Prism::HashNode, Prism::KeywordHashNode, Prism::ArrayNode, Prism::StringNode].freeze
13
+
14
+ STATE = [
15
+ Prism::InstanceVariableReadNode, Prism::InstanceVariableWriteNode,
16
+ Prism::InstanceVariableOrWriteNode, Prism::InstanceVariableAndWriteNode,
17
+ Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableTargetNode
18
+ ].freeze
19
+
20
+ def initialize(subject, ownership)
21
+ node = subject.node
22
+ @body = Hashira::Smells::Scope.inside(node)
23
+ @tail = tail(node)
24
+ @ownership = ownership
25
+ end
26
+
27
+ def dismiss?(name)
28
+ convert? || fenced?(name) || wire?(name) || built?(name) ||
29
+ derived?(name) || rescued?(name)
30
+ end
31
+
32
+ def reaches
33
+ tests { true }.reject { @ownership.owned?(it) }.map(&:first).uniq
34
+ end
35
+
36
+ private
37
+
38
+ def convert?
39
+ @tail.is_a?(Prism::CallNode) && @tail.name == :new &&
40
+ Hashira::Analysis::Syntax.segments(@tail.receiver).any? && stateless?
41
+ end
42
+
43
+ def stateless? = @body.none? { STATE.include?(it.class) }
44
+
45
+ def fenced?(name)
46
+ tested = tests { it == name }
47
+ tested.any? && tested.none? { @ownership.owned?(it) }
48
+ end
49
+
50
+ def wire?(name)
51
+ calls = @body.grep(Prism::CallNode).select { |call| local?(call.receiver) { it == name } }
52
+ calls.any? && calls.all? { keyed?(it) } && reassignments(name).none?
53
+ end
54
+
55
+ def built?(name)
56
+ writes(name).any? { LITERALS.include?(it.value.class) }
57
+ end
58
+
59
+ def derived?(name)
60
+ writes(name).any? { spawned?(it.value) }
61
+ end
62
+
63
+ def rescued?(name)
64
+ snares(name).any? { alien?(it.exceptions) }
65
+ end
66
+
67
+ def keyed?(call)
68
+ names = call.arguments&.arguments
69
+ KEYED_READS.include?(call.name) && names&.any? &&
70
+ names.all? { KEYS.include?(it.class) }
71
+ end
72
+
73
+ def writes(name) = among(Prism::LocalVariableWriteNode, name)
74
+
75
+ def reassignments(name) = among(Prism::LocalVariableOperatorWriteNode, name)
76
+
77
+ def among(kind, name)
78
+ @body.grep(kind).select { it.name == name }
79
+ end
80
+
81
+ def snares(name)
82
+ @body.grep(Prism::RescueNode).select { it.reference&.name == name }
83
+ end
84
+
85
+ def alien?(exceptions)
86
+ exceptions.map { Hashira::Analysis::Syntax.segments(it) }.none? { @ownership.owned?(it) }
87
+ end
88
+
89
+ def spawned?(value)
90
+ value.is_a?(Prism::CallNode) && stranger?(value.receiver)
91
+ end
92
+
93
+ def stranger?(node)
94
+ case node
95
+ when Prism::LocalVariableReadNode then fenced?(node.name)
96
+ when Prism::ConstantReadNode, Prism::ConstantPathNode then unowned?(node)
97
+ else false
98
+ end
99
+ end
100
+
101
+ def unowned?(node)
102
+ !@ownership.owned?(Hashira::Analysis::Syntax.segments(node))
103
+ end
104
+
105
+ def tests(&)
106
+ (probes(&) + arms(&)).map { Hashira::Analysis::Syntax.segments(it) }.reject(&:empty?) + lookups(&)
107
+ end
108
+
109
+ def probes(&)
110
+ @body.grep(Prism::CallNode).select { TYPE_TESTS.include?(it.name) && local?(it.receiver, &) }.filter_map { key(it) }
111
+ end
112
+
113
+ def arms(&)
114
+ @body.grep(Prism::CaseNode).select { local?(it.predicate, &) }.flat_map(&:conditions).flat_map(&:conditions)
115
+ end
116
+
117
+ def lookups(&)
118
+ @body.grep(Prism::CallNode).select { it.name == :[] && sorts?(key(it), &) }.flat_map { @ownership.keys(Hashira::Analysis::Syntax.segments(it.receiver)) }
119
+ end
120
+
121
+ def sorts?(argument, &)
122
+ argument.is_a?(Prism::CallNode) && argument.name == :class &&
123
+ local?(argument.receiver, &)
124
+ end
125
+
126
+ def local?(node, &) = node.is_a?(Prism::LocalVariableReadNode) && yield(node.name)
127
+
128
+ def key(call) = call.arguments&.arguments&.first
129
+
130
+ def tail(def_node) = Hashira::Analysis::Syntax.statements(def_node).compact.last
131
+ end
@@ -0,0 +1,55 @@
1
+ # frozen_string_literal: true
2
+
3
+ class Hashira::Smells::Ownership
4
+ def initialize(trees)
5
+ @suffixes = Set.new
6
+ @tables = {}
7
+ trees.each { survey(it) }
8
+ end
9
+
10
+ def owned?(segments) = @suffixes.include?(segments.join("::"))
11
+
12
+ def keys(segments) = @tables.fetch(segments.join("::"), [])
13
+
14
+ private
15
+
16
+ def survey(tree)
17
+ Hashira::Analysis::TypeWalk.each(tree) do |node, full|
18
+ absorb(full)
19
+ Hashira::Analysis::Syntax.constants(node).each { record(full, it) }
20
+ end
21
+ end
22
+
23
+ def record(full, constant)
24
+ path = full + [constant.name.to_s]
25
+ absorb(path)
26
+ chart(path, thaw(constant.value))
27
+ end
28
+
29
+ def absorb(path)
30
+ @suffixes.merge(suffixes(path))
31
+ end
32
+
33
+ def chart(path, value)
34
+ return unless value.is_a?(Prism::HashNode)
35
+ keys = value.elements.map { spine(it) }
36
+ return if keys.empty? || keys.any?(&:nil?)
37
+ suffixes(path).each { @tables[it] = keys }
38
+ end
39
+
40
+ def thaw(value)
41
+ frozen?(value) ? value.receiver : value
42
+ end
43
+
44
+ def frozen?(value) = value.is_a?(Prism::CallNode) && value.name == :freeze
45
+
46
+ def spine(element)
47
+ return unless element.is_a?(Prism::AssocNode)
48
+ segments = Hashira::Analysis::Syntax.segments(element.key)
49
+ segments unless segments.empty?
50
+ end
51
+
52
+ def suffixes(path)
53
+ path.each_index.map { path.drop(it).join("::") }
54
+ end
55
+ end
@@ -1,14 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "boundary_sprawl"
3
4
  require_relative "check"
4
5
  require_relative "control_parameter"
5
6
  require_relative "data_clump"
6
7
  require_relative "duplicate_method_call"
7
8
  require_relative "feature_envy"
9
+ require_relative "foreign"
8
10
  require_relative "instance_variable_assumption"
9
11
  require_relative "manual_dispatch"
10
12
  require_relative "module_initialize"
11
13
  require_relative "nil_check"
14
+ require_relative "ownership"
12
15
  require_relative "repeated_conditional"
13
16
  require_relative "too_many_instance_variables"
14
17
  require_relative "utility_function"
@@ -21,13 +24,18 @@ class Hashira::Smells::Report
21
24
  PROBES = CHECKS.reject(&:judge?).freeze
22
25
 
23
26
  def initialize(project, trees)
24
- @types = Hashira::Smells::Census.new(project, trees).types
27
+ @census = Hashira::Smells::Census.new(project, trees)
28
+ @types = @census.types
25
29
  end
26
30
 
27
- def findings = @findings ||= sniff(@types, JUDGES) + sniff(@types.flat_map(&:defs), PROBES)
31
+ def findings = @findings ||= sniff(@types, JUDGES) + sniff(methods, PROBES) + sprawl
28
32
 
29
33
  private
30
34
 
35
+ def methods = @types.flat_map(&:defs)
36
+
37
+ def sprawl = Hashira::Smells::BoundarySprawl.new(methods, @census.ownership).findings
38
+
31
39
  def sniff(subjects, checks) = subjects.flat_map { |subject| verdicts(subject, checks) }
32
40
 
33
41
  def verdicts(subject, checks) = checks.filter_map { |check| check.new(subject).finding }
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashira
4
- VERSION = "0.5.1"
4
+ VERSION = "0.6.0"
5
5
  end
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: hashira
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Giacomo GK
@@ -10,10 +10,10 @@ cert_chain: []
10
10
  date: 1980-01-02 00:00:00.000000000 Z
11
11
  dependencies: []
12
12
  description: Measures package coupling (Ca/Ce/instability, SDP violations, cycles),
13
- per-method cognitive complexity, and near-miss code duplication in a Ruby codebase
14
- from the AST, then ranks files by cost against git churn. Findings are backed by
15
- file-level evidence, and a baseline ratchets edges and findings for CI — direction,
16
- not a score.
13
+ per-method cognitive complexity, near-miss code duplication, and twelve design smells
14
+ in a Ruby codebase from the AST, then ranks files by cost against git churn. Findings
15
+ are backed by file-level evidence, and a baseline ratchets edges and findings for
16
+ CI — direction, not a score.
17
17
  email:
18
18
  - giaco@hey.com
19
19
  executables:
@@ -126,6 +126,7 @@ files:
126
126
  - lib/hashira/report/smell_phrases.rb
127
127
  - lib/hashira/report/text.rb
128
128
  - lib/hashira/report/view.rb
129
+ - lib/hashira/smells/boundary_sprawl.rb
129
130
  - lib/hashira/smells/census.rb
130
131
  - lib/hashira/smells/check.rb
131
132
  - lib/hashira/smells/conditions.rb
@@ -134,10 +135,12 @@ files:
134
135
  - lib/hashira/smells/data_clump.rb
135
136
  - lib/hashira/smells/duplicate_method_call.rb
136
137
  - lib/hashira/smells/feature_envy.rb
138
+ - lib/hashira/smells/foreign.rb
137
139
  - lib/hashira/smells/instance_variable_assumption.rb
138
140
  - lib/hashira/smells/manual_dispatch.rb
139
141
  - lib/hashira/smells/module_initialize.rb
140
142
  - lib/hashira/smells/nil_check.rb
143
+ - lib/hashira/smells/ownership.rb
141
144
  - lib/hashira/smells/param_check.rb
142
145
  - lib/hashira/smells/refs.rb
143
146
  - lib/hashira/smells/repeated_conditional.rb
@@ -171,5 +174,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
171
174
  requirements: []
172
175
  rubygems_version: 4.0.17
173
176
  specification_version: 4
174
- summary: Coupling, cognitive-complexity, and duplication metrics for Ruby, via Prism
177
+ summary: Coupling, cognitive-complexity, duplication and code-smell metrics for Ruby,
178
+ via Prism
175
179
  test_files: []