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 +4 -4
- data/CHANGELOG.md +56 -0
- data/README.md +28 -3
- data/lib/hashira/analysis/finding.rb +10 -0
- data/lib/hashira/analysis/type_walk.rb +4 -0
- data/lib/hashira/ci/baseline.rb +23 -7
- data/lib/hashira/ci/comparison.rb +14 -5
- data/lib/hashira/ci/mark.rb +7 -0
- data/lib/hashira/ci/ratchet.rb +3 -1
- data/lib/hashira/ci/slice.rb +7 -3
- data/lib/hashira/ci/sweep.rb +1 -1
- data/lib/hashira/coupling/definitions.rb +1 -7
- data/lib/hashira/duplication/clusters.rb +1 -1
- data/lib/hashira/duplication/fragment.rb +18 -0
- data/lib/hashira/duplication/literal.rb +25 -0
- data/lib/hashira/duplication/variance.rb +7 -1
- data/lib/hashira/report/smell_phrases.rb +2 -2
- data/lib/hashira/smells/branches.rb +62 -0
- data/lib/hashira/smells/census.rb +8 -2
- data/lib/hashira/smells/contexts.rb +3 -1
- data/lib/hashira/smells/duplicate_method_call.rb +24 -4
- data/lib/hashira/smells/instance_variable_assumption.rb +4 -14
- data/lib/hashira/smells/lineage.rb +92 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +4 -0
- metadata +5 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 2750af6be7a5e6885421144e92ab4c12e554ece76c110b76d5fd2b52b72a2411
|
|
4
|
+
data.tar.gz: b2ab7386f4907e297b3cfc623b535808df4f6865423bd9d4f8bae4b487b81317
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
352
|
-
assigns
|
|
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)
|
data/lib/hashira/ci/baseline.rb
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
require "json"
|
|
4
4
|
|
|
5
5
|
class Hashira::CI::Baseline
|
|
6
|
-
SCHEMA_VERSION =
|
|
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,
|
|
41
|
-
File.write(@path, JSON.pretty_generate(payload(edges,
|
|
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,
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
11
|
-
|
|
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
|
|
18
|
-
|
|
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
|
data/lib/hashira/ci/ratchet.rb
CHANGED
|
@@ -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
|
|
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) }
|
data/lib/hashira/ci/slice.rb
CHANGED
|
@@ -9,9 +9,13 @@ class Hashira::CI::Slice
|
|
|
9
9
|
|
|
10
10
|
def edges(_diff) = SETTLED
|
|
11
11
|
|
|
12
|
-
def findings(
|
|
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
|
data/lib/hashira/ci/sweep.rb
CHANGED
|
@@ -7,7 +7,7 @@ class Hashira::CI::Sweep
|
|
|
7
7
|
|
|
8
8
|
def edges(diff) = diff
|
|
9
9
|
|
|
10
|
-
def findings(
|
|
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
|
|
37
|
-
"Assign them
|
|
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
|
|
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)
|
|
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 -
|
|
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
|
data/lib/hashira/version.rb
CHANGED
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.
|
|
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
|