hashira 0.8.0 → 0.9.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: 137dafce03e707a097cf684620fbd9c9efc8989347e77b88c13da103102f5da5
4
- data.tar.gz: e0ab59d2e1909042c991764b5384217601aaf42b036bd6916042efc2107e0200
3
+ metadata.gz: 2750af6be7a5e6885421144e92ab4c12e554ece76c110b76d5fd2b52b72a2411
4
+ data.tar.gz: b2ab7386f4907e297b3cfc623b535808df4f6865423bd9d4f8bae4b487b81317
5
5
  SHA512:
6
- metadata.gz: 2c4895c6d045e05e610fda4c51ce763e0ef876049d933dbd4a3d9f99ac24c8648583a4a3f39e5c2baadfae263736914ba2a428781024ead0410b2d968585b8b8
7
- data.tar.gz: dec6a978383a76ad57fdf34c98b5ea4b571775d44baafc0f5c02c3bbe75b7a56e274609c1586fc77af94e93f65f501579a5b6fa09bfd4db3952bb3cb412c153b
6
+ metadata.gz: 6a4c401b5d242175899bcf4e1e6792f74b77139346b336564c2496e1b97dd716b5014f147b94fbc076b1870020893c8d99b8f08dadd0ef5e648fa427a6fb3a91
7
+ data.tar.gz: 812c604c2cafe644d8ab0692b670f74f1c63dde77a1084f235919c1eee72d1a24c53936c403f7e84c2a46c0145c4bd702c6f183e926f9d5f27135d54b366aca6
data/CHANGELOG.md CHANGED
@@ -5,6 +5,62 @@ 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.9.0] - 2026-08-20
9
+
10
+ ### Fixed
11
+
12
+ - **A rename stops reading as churn in the ratchet.** A finding is keyed by what
13
+ it names, so renaming a class reported the twelve findings under it as
14
+ resolved and new at once, and adding `?` to four predicates did the same. The
15
+ baseline now records a `traces` map beside `findings` — for each finding, the
16
+ file it sits in and what its evidence says, with line numbers stripped — and
17
+ the ratchet pairs a disappeared key with an appeared one carrying the same
18
+ trace. The match is one for one, so a rename that brought a new finding along
19
+ still fails, and a renamed method that also got worse still reports WORSE.
20
+ Acceptance is untouched: `accepted` entries still name a finding by `package`
21
+ or `digest`. Baselines recorded before this have no traces and behave exactly
22
+ as they did; the next `--update-baseline` writes them (schema version 5, which
23
+ older hashira reads too).
24
+ - **A shared namespace stops passing for a shared name.** The duplication
25
+ near-miss guard raises the mass floor when two sites have no name in common —
26
+ but `Prism::CallNode` and `Prism::BlockParameterNode` counted `Prism` as
27
+ common, so two unrelated one-liners that both mention a Prism class slipped
28
+ under the low floor. A constant is now read by what it points at, not the
29
+ namespace it sits in.
30
+ - **A nested class is named the way Ruby resolves it.** `class Widget::Broken`
31
+ written inside `class Widget` was reported as `Widget::Widget::Broken`. The
32
+ smell census now resolves a compound constant path against the constants the
33
+ codebase actually declares, the way the coupling graph already did. Findings
34
+ on such classes change name, so a baseline recorded before this reports them
35
+ once as resolved-and-new; re-record it with `--update-baseline`.
36
+
37
+ ### Changed
38
+
39
+ - **`duplicate_method_call` stops flagging calls that are supposed to differ.**
40
+ `stdout = "".b` next to `stderr = "".b` is two buffers, and
41
+ `rand(1_000_000_000)` twice is two ids — naming either once writes a bug. The
42
+ check now excuses calls that mint a fresh value each time (`new`, `dup`,
43
+ `clone`, `allocate`, `rand`, anything from `SecureRandom` or `Random`, and
44
+ any call on a literal) and repeats that no single run can reach twice: the
45
+ two arms of an `if` or `unless`, two `when` or `in` branches, a body and its
46
+ `rescue`. Nothing can be hoisted across those, and a `raise` has no result to
47
+ name.
48
+ - **`instance_variable_assumption` asks whether anything assigns, not whether
49
+ `initialize` does.** Assignment through a mixin, a superclass, an
50
+ `attr_writer`, a reopening of the class, or a private method the constructor
51
+ calls all count now — every shape that made the old check report a class that
52
+ was perfectly fine. What survives is the ivar nothing the class can reach ever
53
+ sets: a typo, or state another object is expected to install. When a class
54
+ inherits or includes something the codebase can't see, the check stays quiet
55
+ rather than guess.
56
+ - **A run of declarative macros is a schema, not a clone.** Two models opening
57
+ with the same `has_many ..., dependent: :destroy` lines, or two serializers
58
+ with the same `typelize`/`attribute` pairs, were reported as duplication whose
59
+ only fix was to hide the schema behind a class method. A fragment built purely
60
+ from directives — receiverless calls with literal arguments — no longer
61
+ clusters. A block, a method, a variable, a receiver, or a branch anywhere in
62
+ the fragment makes it code again.
63
+
8
64
  ## [0.8.0] - 2026-08-15
9
65
 
10
66
  ### Added
data/README.md CHANGED
@@ -288,6 +288,12 @@ it does inside Ruby:
288
288
  - **Lists aren't clones.** A run of identically shaped statements — a require
289
289
  block, a routes file, a column of registrations — is skipped, so windows cut
290
290
  out of one don't report a match at every offset.
291
+ - **Declarations aren't clones either.** A fragment built only from directives —
292
+ receiverless macro calls with literal arguments, the `has_many` /
293
+ `validates` / `attribute` spine of a model or a serializer — is a schema, not
294
+ copied logic; extracting it only hides what the class declares. Two models
295
+ that open the same way are two models. As soon as a fragment carries logic —
296
+ a block, a method, a variable, a receiver, a branch — it counts again.
291
297
  - **Clusters, not pairs.** All copies of one thing collapse into a single
292
298
  finding with N sites, so the report reads as "fix this once," not a wall of
293
299
  pairwise matches.
@@ -343,13 +349,21 @@ What each one catches:
343
349
  - **data_clump** — the same two-plus parameters travel through three or more
344
350
  methods; a value object is missing.
345
351
  - **duplicate_method_call** — the identical receiver-and-arguments call repeated
346
- inside one method; name the result once.
352
+ inside one method; name the result once. Quiet wherever naming it would be
353
+ wrong: calls that mint a fresh value every time (`"".b`, `rand`, `dup`,
354
+ `SecureRandom.hex`) are meant to differ, and a repeat no single run can reach
355
+ twice — the two arms of an `if`, two `when` branches, a body and its `rescue`
356
+ — has nothing to hoist.
347
357
  - **repeated_conditional** — one class testing the same condition in three or
348
358
  more places; polymorphism is overdue.
349
359
  - **too_many_instance_variables** — more than four per class. Memoization
350
360
  (`@x ||=`) doesn't count as state.
351
- - **instance_variable_assumption** — an ivar read that no `initialize` ever
352
- assigns; the reader is assuming another method ran first.
361
+ - **instance_variable_assumption** — an ivar read that nothing the class can
362
+ reach ever assigns: not `initialize`, not another of its own methods, not an
363
+ `attr_writer`, not a reopening of the class, not a module it mixes in or a
364
+ class it inherits. Usually a typo, or state some other object is expected to
365
+ install. Silent when the class inherits or includes something the codebase
366
+ can't see, because the assignment may live in there.
353
367
  - **manual_dispatch** — `respond_to?` then send: a type check wearing a duck
354
368
  costume.
355
369
  - **module_initialize** — `initialize` in a mixin; construction order becomes
@@ -460,6 +474,17 @@ WORSE FINDING (was 13, now 24):
460
474
  Baselines written by earlier versions still work: they record identity only, so
461
475
  they ratchet on appearance until the next `--update-baseline` records magnitudes.
462
476
 
477
+ It also records a *trace* of each finding — the file it sits in and what it says,
478
+ with the line numbers left out. A finding is keyed by what it names
479
+ (`Class#method`), so renaming a class, or adding a `?` to four predicates, would
480
+ otherwise report every finding under it as resolved and new in the same breath.
481
+ When a key disappears and another appears carrying the same trace, the ratchet
482
+ reads them as one finding that changed name. The match is one for one: a rename
483
+ that *brought* a new finding with it still fails, and a renamed method that also
484
+ got more complex still reports WORSE. Moving the file changes the trace, because
485
+ that is a relocation rather than a rename — re-record it. Baselines without
486
+ traces behave exactly as they did before.
487
+
463
488
  A regression prints in full, with the evidence that introduced it:
464
489
 
465
490
  ```console
@@ -4,6 +4,8 @@ module Hashira
4
4
  module Analysis
5
5
  MAGNITUDES = { "complexity" => :cognitive, "duplication" => :mass, "boundary_sprawl" => :count }.freeze
6
6
 
7
+ TRACKS = [/ \(lines? [\d, ]+\)/, /:[\d, -]+\z/, /:\d+(?=:)/].freeze
8
+
7
9
  Finding =
8
10
  Data.define(:kind, :package, :detail, :evidence, :cycle, :digest) do
9
11
  def initialize(cycle: nil, digest: nil, detail: nil, **rest) = super
@@ -14,6 +16,14 @@ module Hashira
14
16
 
15
17
  def identity = digest || package
16
18
 
19
+ def trace
20
+ "#{kind}|#{plain(site)}|#{evidence.map { plain(it) }.join(";")}" unless digest
21
+ end
22
+
23
+ def site = detail.to_h[:site].to_s
24
+
25
+ def plain(text) = TRACKS.reduce(text) { |left, mark| left.gsub(mark, "") }
26
+
17
27
  def to_h = super.merge(detail: detail&.to_h).compact
18
28
  end
19
29
  end
@@ -7,6 +7,10 @@ module Hashira
7
7
  module TypeWalk
8
8
  module_function
9
9
 
10
+ def roots(trees)
11
+ trees.each_value.with_object(Set.new) { |tree, set| each(tree) { |_node, full| set << full } }
12
+ end
13
+
10
14
  def each(node, stack = [], roots: nil, &)
11
15
  return descend(node, stack, roots, &) unless type?(node)
12
16
  full = Syntax.anchor(stack, Syntax.segments(node.constant_path), roots)
@@ -3,7 +3,7 @@
3
3
  require "json"
4
4
 
5
5
  class Hashira::CI::Baseline
6
- SCHEMA_VERSION = 4
6
+ SCHEMA_VERSION = 5
7
7
 
8
8
  def initialize(path, analyzers: [], targets: [])
9
9
  @path = path
@@ -25,6 +25,10 @@ class Hashira::CI::Baseline
25
25
 
26
26
  def findings = keyed(recorded.fetch("findings", {}))
27
27
 
28
+ def marks = findings.to_h { |key, magnitude| [key, Hashira::CI::Mark.new(magnitude:, trace: traces[key])] }
29
+
30
+ def traces = recorded.fetch("traces", {})
31
+
28
32
  def findings? = recorded.key?("findings")
29
33
 
30
34
  def accepted = recorded.fetch("accepted", [])
@@ -37,8 +41,8 @@ class Hashira::CI::Baseline
37
41
 
38
42
  def wanted = scope.to_h
39
43
 
40
- def write(edges, findings, packaging:)
41
- File.write(@path, JSON.pretty_generate(payload(edges, findings, packaging)) << "\n")
44
+ def write(edges, marks, packaging:)
45
+ File.write(@path, JSON.pretty_generate(payload(edges, marks, packaging)) << "\n")
42
46
  end
43
47
 
44
48
  private
@@ -57,9 +61,21 @@ class Hashira::CI::Baseline
57
61
 
58
62
  def scope = Hashira::CI::Scope.new(analyzers: @analyzers, targets: @targets)
59
63
 
60
- def payload(edges, findings, packaging)
61
- base = { version: SCHEMA_VERSION, packaging: }.merge(scope.to_h).merge(edges:, findings:)
62
- kept = Hashira::CI::Accepted.new(accepted).entries
63
- kept.empty? ? base : base.merge(accepted: kept)
64
+ def payload(edges, marks, packaging)
65
+ stem(packaging).merge(edges:, findings: sized(marks)).merge(traced(marks)).merge(kept)
66
+ end
67
+
68
+ def stem(packaging) = { version: SCHEMA_VERSION, packaging: }.merge(scope.to_h)
69
+
70
+ def sized(marks) = marks.transform_values(&:magnitude)
71
+
72
+ def traced(marks)
73
+ found = marks.transform_values(&:trace).compact
74
+ found.empty? ? {} : { traces: found }
75
+ end
76
+
77
+ def kept
78
+ entries = Hashira::CI::Accepted.new(accepted).entries
79
+ entries.empty? ? {} : { accepted: entries }
64
80
  end
65
81
  end
@@ -7,17 +7,26 @@ class Hashira::CI::Comparison
7
7
  end
8
8
 
9
9
  def diff
10
- current = @current.keys
11
- recorded = @recorded.keys
12
- Hashira::CI::Diff.new(added: current - recorded, removed: recorded - current, worsened:)
10
+ moved = renamed
11
+ Hashira::CI::Diff.new(added: fresh - moved.keys, removed: gone - moved.values, worsened: worsened(moved))
13
12
  end
14
13
 
15
14
  private
16
15
 
17
- def worsened
18
- (@current.keys & @recorded.keys).filter_map { entry(it, @recorded[it], @current[it]) }
16
+ def fresh = @current.keys - @recorded.keys
17
+
18
+ def gone = @recorded.keys - @current.keys
19
+
20
+ def renamed = fresh.to_h { [it, pool[@current[it].trace]&.shift] }.compact
21
+
22
+ def pool = @_pool ||= gone.group_by { @recorded[it].trace }.except(nil)
23
+
24
+ def worsened(moved)
25
+ paired(moved).filter_map { |key, was| entry(key, @recorded[was].magnitude, @current[key].magnitude) }
19
26
  end
20
27
 
28
+ def paired(moved) = (@current.keys & @recorded.keys).to_h { [it, it] }.merge(moved)
29
+
21
30
  def entry(key, before, after)
22
31
  [key, before, after] if before.is_a?(Integer) && after.is_a?(Integer) && after > before
23
32
  end
@@ -0,0 +1,7 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module CI
5
+ Mark = Data.define(:magnitude, :trace)
6
+ end
7
+ end
@@ -63,9 +63,11 @@ class Hashira::CI::Ratchet
63
63
  def tally(compared) = compared.counts("#{@graph.edges.size} edges", "#{@findings.size} findings").join(", ")
64
64
 
65
65
  def scored
66
- @_scored ||= @findings.group_by(&:signature).sort.to_h { |key, group| [key, group.filter_map(&:magnitude).max] }
66
+ @_scored ||= @findings.group_by(&:signature).sort.to_h { |key, group| [key, mark(group)] }
67
67
  end
68
68
 
69
+ def mark(group) = Hashira::CI::Mark.new(magnitude: group.filter_map(&:magnitude).max, trace: group.first.trace)
70
+
69
71
  def drift = Hashira::CI::Diff.new(added: fresh, removed: @baseline.edges - edges)
70
72
 
71
73
  def fresh = @graph.edges.reject { @baseline.edges.include?(it.to_s) }
@@ -9,9 +9,13 @@ class Hashira::CI::Slice
9
9
 
10
10
  def edges(_diff) = SETTLED
11
11
 
12
- def findings(scored) = Hashira::CI::Comparison.new(scored, seen(scored)).diff
13
-
14
- def seen(scored) = @baseline.findings.slice(*scored.keys)
12
+ def findings(marks) = Hashira::CI::Comparison.new(marks, seen(marks, traces(marks))).diff.with(removed: [])
15
13
 
16
14
  def counts(_edges, findings) = [findings]
15
+
16
+ private
17
+
18
+ def traces(marks) = marks.each_value.filter_map(&:trace)
19
+
20
+ def seen(marks, traces) = @baseline.marks.select { |key, mark| marks.key?(key) || traces.include?(mark.trace) }
17
21
  end
@@ -7,7 +7,7 @@ class Hashira::CI::Sweep
7
7
 
8
8
  def edges(diff) = diff
9
9
 
10
- def findings(scored) = Hashira::CI::Comparison.new(scored, @baseline.findings).diff
10
+ def findings(marks) = Hashira::CI::Comparison.new(marks, @baseline.marks).diff
11
11
 
12
12
  def counts(edges, findings) = [edges, findings]
13
13
  end
@@ -14,16 +14,10 @@ class Hashira::Coupling::Definitions
14
14
 
15
15
  def packages = @trees.keys.map { @project.package(it) }.uniq
16
16
 
17
- def roots
18
- @_roots ||= @trees.each_value.with_object(Set.new) { |tree, set| survey(tree, set) }
19
- end
17
+ def roots = @_roots ||= Hashira::Analysis::TypeWalk.roots(@trees)
20
18
 
21
19
  private
22
20
 
23
- def survey(tree, set)
24
- Hashira::Analysis::TypeWalk.each(tree) { |_node, full| set << full }
25
- end
26
-
27
21
  def scan(file, tree)
28
22
  package = @project.package(file)
29
23
  Hashira::Analysis::TypeWalk.each(tree, roots: roots) do |node, full|
@@ -19,7 +19,7 @@ class Hashira::Duplication::Clusters
19
19
 
20
20
  private
21
21
 
22
- def fragments = @_fragments ||= @all.select { |fragment| fragment.mass >= PREFILTER }
22
+ def fragments = @_fragments ||= @all.select { |fragment| fragment.mass >= PREFILTER }.reject(&:schema?)
23
23
 
24
24
  def sets = @_sets ||= Hashira::Duplication::UnionFind.new
25
25
 
@@ -1,10 +1,17 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require "digest"
4
+ require "prism"
4
5
 
5
6
  class Hashira::Duplication::Fragment
6
7
  DIGEST_LENGTH = 12
7
8
 
9
+ SCHEMA = %i[
10
+ class_node module_node statements_node arguments_node assoc_node
11
+ array_node hash_node keyword_hash_node constant_read_node constant_path_node
12
+ symbol_node string_node integer_node float_node true_node false_node nil_node
13
+ ].freeze
14
+
8
15
  def initialize(file, roots)
9
16
  @file = file
10
17
  @roots = roots
@@ -20,6 +27,8 @@ class Hashira::Duplication::Fragment
20
27
 
21
28
  def mass = types.size
22
29
 
30
+ def schema? = nodes.all? { directive?(it) }
31
+
23
32
  def line = @roots.first.location.start_line
24
33
 
25
34
  def finish = @roots.last.location.end_line
@@ -35,4 +44,13 @@ class Hashira::Duplication::Fragment
35
44
  def touches?(others) = others.any? { overlaps?(it) }
36
45
 
37
46
  def nodes = @_nodes ||= @roots.flat_map { Hashira::Analysis::NodeWalk.collect(it) }
47
+
48
+ private
49
+
50
+ def directive?(node)
51
+ return SCHEMA.include?(node.type) unless node.is_a?(Prism::CallNode)
52
+ !node.receiver && !node.block && literals?(node.arguments)
53
+ end
54
+
55
+ def literals?(arguments) = Hashira::Duplication::Literal.new(arguments).literals?
38
56
  end
@@ -0,0 +1,25 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Duplication::Literal
6
+ TYPES = [
7
+ Prism::ArrayNode, Prism::AssocNode, Prism::FalseNode, Prism::FloatNode,
8
+ Prism::HashNode, Prism::IntegerNode, Prism::KeywordHashNode, Prism::NilNode,
9
+ Prism::StringNode, Prism::SymbolNode, Prism::TrueNode
10
+ ].freeze
11
+
12
+ def initialize(node)
13
+ @node = node
14
+ end
15
+
16
+ def literal? = TYPES.include?(@node.class) && parts.all?(&:literal?)
17
+
18
+ def literals? = parts.all?(&:literal?)
19
+
20
+ private
21
+
22
+ def parts = children.map { Hashira::Duplication::Literal.new(it) }
23
+
24
+ def children = Array(@node&.compact_child_nodes)
25
+ end
@@ -1,5 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require "prism"
4
+
3
5
  class Hashira::Duplication::Variance
4
6
  LITERALS = %i[integer_node float_node string_node symbol_node].freeze
5
7
  VALUED = %i[integer_node float_node].freeze
@@ -27,7 +29,11 @@ class Hashira::Duplication::Variance
27
29
 
28
30
  def pairs = @canonical.nodes.zip(@other.nodes)
29
31
 
30
- def named = @_named ||= pairs.select { |left, _| NAMED.include?(left.type) }
32
+ def named = @_named ||= pairs.select { |left, _| NAMED.include?(left.type) && !inner?(left) }
33
+
34
+ def inner?(node) = nested.any? { it.equal?(node) }
35
+
36
+ def nested = @_nested ||= @canonical.nodes.grep(Prism::ConstantPathNode).filter_map(&:parent)
31
37
 
32
38
  def differing = pairs.select { |pair| varies?(*pair) }.map(&:first)
33
39
 
@@ -33,8 +33,8 @@ module Hashira::Report::Phrases
33
33
  end
34
34
 
35
35
  def on_instance_variable_assumption(finding)
36
- "#{finding.package} reads instance variables never set in initialize (#{finding.detail[:site]}). " \
37
- "Assign them in initialize, or pass the data explicitly."
36
+ "#{finding.package} reads instance variables nothing in the class assigns (#{finding.detail[:site]}). " \
37
+ "Assign them where the object is built, or pass the data explicitly."
38
38
  end
39
39
 
40
40
  def on_manual_dispatch(finding)
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Smells::Branches
6
+ def initialize(root)
7
+ @root = root
8
+ end
9
+
10
+ def together?(nodes) = nodes.combination(2).any? { |left, right| meet?(trail(left), trail(right)) }
11
+
12
+ private
13
+
14
+ def meet?(left, right) = shared(left, right).none? { parted?(*it) }
15
+
16
+ def shared(left, right) = left.zip(right).select(&:last)
17
+
18
+ def parted?(here, there) = here.first.equal?(there.first) && here.last != there.last
19
+
20
+ def trail(node) = trails.fetch(node)
21
+
22
+ def trails
23
+ return @_trails if @_trails
24
+ @_trails = {}.compare_by_identity
25
+ chart(@root, [])
26
+ @_trails
27
+ end
28
+
29
+ def chart(node, trail)
30
+ @_trails[node] = trail
31
+ node.compact_child_nodes.each { chart(it, trail + taken(node, it)) }
32
+ end
33
+
34
+ def taken(node, child)
35
+ return arm_for(node, child) if node.is_a?(Prism::BeginNode)
36
+ arm = arms(node).index { it.equal?(child) }
37
+ arm ? [[node, arm]] : []
38
+ end
39
+
40
+ def arm_for(node, child)
41
+ rescued = node.rescue_clause
42
+ return [] unless [node.statements, rescued, node.else_clause].any? { it.equal?(child) }
43
+ [[node, rescued.equal?(child) ? 1 : 0]]
44
+ end
45
+
46
+ def arms(node) = (clauses(node) || conditions(node)).compact
47
+
48
+ def clauses(node)
49
+ case node
50
+ when Prism::IfNode, Prism::RescueNode then [node.statements, node.subsequent]
51
+ when Prism::UnlessNode then [node.statements, node.else_clause]
52
+ when Prism::RescueModifierNode then [node.expression, node.rescue_expression]
53
+ end
54
+ end
55
+
56
+ def conditions(node)
57
+ case node
58
+ when Prism::CaseNode, Prism::CaseMatchNode then node.conditions + [node.else_clause]
59
+ else []
60
+ end
61
+ end
62
+ end
@@ -10,13 +10,19 @@ class Hashira::Smells::Census
10
10
 
11
11
  def ownership = @_ownership ||= Hashira::Smells::Ownership.new(@trees.values)
12
12
 
13
- def types = @trees.flat_map { |path, tree| harvest(@project.relative(path), tree) }
13
+ def types = @_types ||= placed(@trees.flat_map { |path, tree| harvest(@project.relative(path), tree) })
14
14
 
15
15
  private
16
16
 
17
+ def placed(found) = settled(found, Hashira::Smells::Lineage.new(found))
18
+
19
+ def settled(found, lineage) = found.map { it.with(assigned: lineage.assigned(it)) }
20
+
21
+ def roots = @_roots ||= Hashira::Analysis::TypeWalk.roots(@trees)
22
+
17
23
  def harvest(file, tree)
18
24
  found = []
19
- Hashira::Analysis::TypeWalk.each(tree) { |node, full| found << context(file, node, full.join("::")) }
25
+ Hashira::Analysis::TypeWalk.each(tree, roots:) { |node, full| found << context(file, node, full.join("::")) }
20
26
  found
21
27
  end
22
28
 
@@ -51,7 +51,9 @@ module Hashira
51
51
  end
52
52
 
53
53
  TypeContext =
54
- Data.define(:name, :node, :kind, :file, :defs) do
54
+ Data.define(:name, :node, :kind, :file, :defs, :assigned) do
55
+ def initialize(assigned: nil, **rest) = super
56
+
55
57
  def line = node.location.start_line
56
58
 
57
59
  def subject = name
@@ -5,23 +5,43 @@ require "prism"
5
5
  class Hashira::Smells::DuplicateMethodCall < Hashira::Smells::Check
6
6
  LIMIT = 1
7
7
 
8
+ MINTS = %i[new dup clone allocate rand srand].freeze
9
+
10
+ SOURCES = %w[SecureRandom Random].freeze
11
+
12
+ LITERALS = [
13
+ Prism::StringNode, Prism::SymbolNode, Prism::ArrayNode, Prism::HashNode,
14
+ Prism::IntegerNode, Prism::FloatNode, Prism::RegularExpressionNode
15
+ ].freeze
16
+
8
17
  private
9
18
 
10
19
  def smelly? = repeats.any?
11
20
 
12
- def calls = @_calls ||= Hashira::Smells::Scope.inside(subject.node).grep(Prism::CallNode)
21
+ def calls
22
+ @_calls ||= Hashira::Smells::Scope.inside(subject.node).grep(Prism::CallNode).reject { minted?(it) }
23
+ end
24
+
25
+ def minted?(node) = mints?(node.name) || spawns?(node.receiver)
26
+
27
+ def mints?(name) = MINTS.include?(name)
28
+
29
+ def spawns?(receiver) = LITERALS.include?(receiver.class) || SOURCES.include?(receiver&.slice)
13
30
 
14
31
  def plain?(node)
15
32
  !node.receiver && !node.arguments && !node.block.is_a?(Prism::BlockArgumentNode)
16
33
  end
17
34
 
18
35
  def repeats
19
- @_repeats ||= usual.reject { |_handle, nodes| whole.value?(nodes) }.merge(whole)
36
+ @_repeats ||= together(usual.reject { |_handle, nodes| whole.value?(nodes) }.merge(whole))
20
37
  end
21
38
 
39
+ def together(groups) = groups.select { |_handle, nodes| branches.together?(nodes) }
40
+
41
+ def branches = @_branches ||= Hashira::Smells::Branches.new(subject.node)
42
+
22
43
  def usual
23
- calls.reject { plain?(it) || it.name == :new }
24
- .group_by { handle(it) }.select { |_handle, group| group.size > LIMIT }
44
+ calls.reject { plain?(it) }.group_by { handle(it) }.select { |_handle, group| group.size > LIMIT }
25
45
  end
26
46
 
27
47
  def whole
@@ -3,12 +3,6 @@
3
3
  require "prism"
4
4
 
5
5
  class Hashira::Smells::InstanceVariableAssumption < Hashira::Smells::Check
6
- SETTERS = [
7
- Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode,
8
- Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode,
9
- Prism::InstanceVariableTargetNode
10
- ].freeze
11
-
12
6
  MAYBES = [
13
7
  Prism::LocalVariableOrWriteNode, Prism::InstanceVariableOrWriteNode,
14
8
  Prism::ClassVariableOrWriteNode, Prism::GlobalVariableOrWriteNode,
@@ -28,19 +22,15 @@ class Hashira::Smells::InstanceVariableAssumption < Hashira::Smells::Check
28
22
 
29
23
  private
30
24
 
31
- def smelly? = subject.kind == :class && assumed.any?
25
+ def smelly? = subject.kind == :class && assigned && assumed.any?
26
+
27
+ def assigned = subject.assigned
32
28
 
33
- def assumed = @_assumed ||= (read - prepared).uniq.sort.reject { cache?(it) }
29
+ def assumed = @_assumed ||= (read - assigned).uniq.sort.reject { cache?(it) }
34
30
 
35
31
  def cache?(name) = name.start_with?("@_")
36
32
 
37
33
  def read = subject.owned.flat_map { Harvest.reads(it.node) }
38
34
 
39
- def prepared
40
- starters.flat_map { Hashira::Smells::Scope.inside(it.node) }.select { SETTERS.include?(it.class) }.map(&:name)
41
- end
42
-
43
- def starters = subject.owned.select { it.node.name == :initialize }
44
-
45
35
  def evidence = assumed.map(&:to_s)
46
36
  end
@@ -0,0 +1,92 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "prism"
4
+
5
+ class Hashira::Smells::Lineage
6
+ MIXINS = %i[include prepend].freeze
7
+
8
+ EXTENSIONS = %i[extend].freeze
9
+
10
+ WRITERS = %i[attr_writer attr_accessor].freeze
11
+
12
+ SETTERS = [
13
+ Prism::InstanceVariableWriteNode, Prism::InstanceVariableOrWriteNode,
14
+ Prism::InstanceVariableAndWriteNode, Prism::InstanceVariableOperatorWriteNode,
15
+ Prism::InstanceVariableTargetNode
16
+ ].freeze
17
+
18
+ def initialize(types)
19
+ @types = types
20
+ end
21
+
22
+ def assigned(context) = kin(context)&.flat_map { writes(it) }&.uniq
23
+
24
+ private
25
+
26
+ def index = @_index ||= @types.group_by(&:name)
27
+
28
+ def kin(context) = walk([context.name], [])&.flat_map { index.fetch(it) }
29
+
30
+ def walk(queue, known)
31
+ return known if queue.empty?
32
+ name, *rest = queue
33
+ return walk(rest, known) if known.include?(name)
34
+ onward(rest, known + [name], parents(index.fetch(name)))
35
+ end
36
+
37
+ def onward(queue, known, found)
38
+ walk(queue + found, known) if found
39
+ end
40
+
41
+ def parents(kin)
42
+ found = kin.flat_map { pointed(it) }
43
+ found unless found.include?(nil) || kin.any? { opaque?(it) }
44
+ end
45
+
46
+ def opaque?(type) = extensions(type).any? { !resolve(type.name, Hashira::Analysis::Syntax.segments(it)) }
47
+
48
+ def extensions(type) = named(type, EXTENSIONS)
49
+
50
+ def pointed(type) = references(type).map { resolve(type.name, it) }
51
+
52
+ def references(type)
53
+ (named(type, MIXINS) + [parent(type)].compact).map { Hashira::Analysis::Syntax.segments(it) }
54
+ end
55
+
56
+ def parent(type) = (type.node.superclass if type.kind == :class)
57
+
58
+ def resolve(owner, segments) = candidates(owner, segments).find { index.key?(it) }
59
+
60
+ def candidates(owner, segments)
61
+ segments.empty? ? [] : scopes(owner).map { (it + segments).join("::") }
62
+ end
63
+
64
+ def scopes(owner)
65
+ parts = owner.split("::")
66
+ parts.size.downto(0).map { parts.first(it) }
67
+ end
68
+
69
+ def writes(type)
70
+ definitions(type).flat_map { setters(it) } + attributes(type)
71
+ end
72
+
73
+ def definitions(type) = sweep(type).grep(Prism::DefNode).reject(&:receiver)
74
+
75
+ def setters(node) = Hashira::Smells::Scope.sweep(node).select { SETTERS.include?(it.class) }.map(&:name)
76
+
77
+ def attributes(type)
78
+ named(type, WRITERS).select { it.is_a?(Prism::SymbolNode) || it.is_a?(Prism::StringNode) }.map { :"@#{it.unescaped}" }
79
+ end
80
+
81
+ def named(type, names) = passed(calls(type).select { names.include?(it.name) })
82
+
83
+ def passed(calls) = calls.flat_map { it.arguments.arguments }
84
+
85
+ def calls(type) = sweep(type).grep(Prism::CallNode).reject(&:receiver).select(&:arguments)
86
+
87
+ def sweep(type) = swept.fetch(type.node) { store(it) }
88
+
89
+ def store(node) = swept[node] = Hashira::Smells::Scope.sweep(node)
90
+
91
+ def swept = @_swept ||= {}.compare_by_identity
92
+ end
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashira
4
- VERSION = "0.8.0"
4
+ VERSION = "0.9.0"
5
5
  end
data/lib/hashira.rb CHANGED
@@ -43,6 +43,7 @@ require_relative "hashira/ci/edge_diff_report"
43
43
  require_relative "hashira/ci/finding_diff_report"
44
44
  require_relative "hashira/ci/gate"
45
45
  require_relative "hashira/ci/improvement"
46
+ require_relative "hashira/ci/mark"
46
47
  require_relative "hashira/ci/ratchet"
47
48
  require_relative "hashira/ci/ratchet_report"
48
49
  require_relative "hashira/ci/scope"
@@ -116,6 +117,7 @@ require_relative "hashira/duplication/fragment"
116
117
  require_relative "hashira/duplication/grouping"
117
118
  require_relative "hashira/duplication/harvest"
118
119
  require_relative "hashira/duplication/index"
120
+ require_relative "hashira/duplication/literal"
119
121
  require_relative "hashira/duplication/maximal"
120
122
  require_relative "hashira/duplication/near_miss"
121
123
  require_relative "hashira/duplication/sequence"
@@ -141,6 +143,7 @@ require_relative "hashira/report/phrases"
141
143
  require_relative "hashira/report/smell_phrases"
142
144
  require_relative "hashira/report/text"
143
145
  require_relative "hashira/report/view"
146
+ require_relative "hashira/smells/branches"
144
147
  require_relative "hashira/smells/census"
145
148
  require_relative "hashira/smells/check"
146
149
  require_relative "hashira/smells/kind"
@@ -151,6 +154,7 @@ require_relative "hashira/smells/data_clump"
151
154
  require_relative "hashira/smells/duplicate_method_call"
152
155
  require_relative "hashira/smells/feature_envy"
153
156
  require_relative "hashira/smells/instance_variable_assumption"
157
+ require_relative "hashira/smells/lineage"
154
158
  require_relative "hashira/smells/manual_dispatch"
155
159
  require_relative "hashira/smells/module_initialize"
156
160
  require_relative "hashira/smells/nil_check"
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.8.0
4
+ version: 0.9.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Giacomo GK
@@ -39,6 +39,7 @@ files:
39
39
  - lib/hashira/ci/finding_diff_report.rb
40
40
  - lib/hashira/ci/gate.rb
41
41
  - lib/hashira/ci/improvement.rb
42
+ - lib/hashira/ci/mark.rb
42
43
  - lib/hashira/ci/ratchet.rb
43
44
  - lib/hashira/ci/ratchet_report.rb
44
45
  - lib/hashira/ci/scope.rb
@@ -113,6 +114,7 @@ files:
113
114
  - lib/hashira/duplication/grouping.rb
114
115
  - lib/hashira/duplication/harvest.rb
115
116
  - lib/hashira/duplication/index.rb
117
+ - lib/hashira/duplication/literal.rb
116
118
  - lib/hashira/duplication/maximal.rb
117
119
  - lib/hashira/duplication/near_miss.rb
118
120
  - lib/hashira/duplication/sequence.rb
@@ -140,6 +142,7 @@ files:
140
142
  - lib/hashira/report/text.rb
141
143
  - lib/hashira/report/view.rb
142
144
  - lib/hashira/smells/boundary_sprawl.rb
145
+ - lib/hashira/smells/branches.rb
143
146
  - lib/hashira/smells/census.rb
144
147
  - lib/hashira/smells/check.rb
145
148
  - lib/hashira/smells/conditions.rb
@@ -151,6 +154,7 @@ files:
151
154
  - lib/hashira/smells/foreign.rb
152
155
  - lib/hashira/smells/instance_variable_assumption.rb
153
156
  - lib/hashira/smells/kind.rb
157
+ - lib/hashira/smells/lineage.rb
154
158
  - lib/hashira/smells/manual_dispatch.rb
155
159
  - lib/hashira/smells/module_initialize.rb
156
160
  - lib/hashira/smells/nil_check.rb