hashira 0.5.0 → 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 +4 -4
- data/CHANGELOG.md +58 -0
- data/README.md +16 -8
- data/lib/hashira/analysis/syntax.rb +6 -2
- data/lib/hashira/churn.rb +1 -1
- data/lib/hashira/cli/command_line.rb +10 -49
- data/lib/hashira/cli/fail_on.rb +1 -7
- data/lib/hashira/cli/flag.rb +29 -0
- data/lib/hashira/cli/flags.rb +50 -0
- data/lib/hashira/cli/format.rb +13 -0
- data/lib/hashira/cli/options.rb +2 -0
- data/lib/hashira/cli/usage.rb +17 -21
- data/lib/hashira/cli.rb +1 -1
- data/lib/hashira/complexity/method_finding.rb +3 -1
- data/lib/hashira/coupling/census.rb +1 -1
- data/lib/hashira/coupling/constant_registry.rb +5 -4
- data/lib/hashira/coupling/definition.rb +5 -1
- data/lib/hashira/coupling/definitions.rb +4 -1
- data/lib/hashira/coupling/roster.rb +4 -0
- data/lib/hashira/coupling/scope.rb +5 -1
- data/lib/hashira/duplication/duplication_finding.rb +3 -1
- data/lib/hashira/pipeline.rb +5 -0
- data/lib/hashira/report/smell_phrases.rb +6 -0
- data/lib/hashira/smells/boundary_sprawl.rb +43 -0
- data/lib/hashira/smells/census.rb +4 -1
- data/lib/hashira/smells/check.rb +5 -1
- data/lib/hashira/smells/contexts.rb +1 -1
- data/lib/hashira/smells/data_clump.rb +2 -0
- data/lib/hashira/smells/feature_envy.rb +3 -1
- data/lib/hashira/smells/foreign.rb +131 -0
- data/lib/hashira/smells/instance_variable_assumption.rb +2 -0
- data/lib/hashira/smells/module_initialize.rb +2 -0
- data/lib/hashira/smells/ownership.rb +55 -0
- data/lib/hashira/smells/repeated_conditional.rb +2 -0
- data/lib/hashira/smells/report.rb +29 -18
- data/lib/hashira/smells/too_many_instance_variables.rb +2 -0
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +3 -0
- metadata +13 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: a0b871ab3e0bf096a2752a48a4c3776d2dbe2ee0cae33b2df8af89649d0952ca
|
|
4
|
+
data.tar.gz: b1058810a8d965e1010044691d1b11571f21b077581f5e435d40b0d263f133b9
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: 24d9aae54c58e39a54799efdf9749f501000162c8ea8f9a38e02523fb6ee3122a98daf6591cfaf7816f3a2cd66f20218c08ff27579bc1b58a269e4ce7bddda92
|
|
7
|
+
data.tar.gz: 158bf3af24e081df3483e3d124833f6943404cfdab6ca1f2f7235115a400cfb1c48f885fb16b290fc529ebc1556c8eb5ed97119f635c9869ae881b44be51c599
|
data/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,63 @@ 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
|
+
|
|
38
|
+
## [0.5.1] - 2026-08-05
|
|
39
|
+
|
|
40
|
+
### Fixed
|
|
41
|
+
|
|
42
|
+
- The churn scan passes `--no-renames` to `git log`, counting a move as a
|
|
43
|
+
delete plus an add. Rename detection needs blob contents, so on a partial
|
|
44
|
+
clone (`--filter=blob:none`) the old command lazy-fetched objects from the
|
|
45
|
+
network one at a time — on a long history the scan stalled for minutes and
|
|
46
|
+
looked like a hang. It also made the tally depend on which blobs git could
|
|
47
|
+
see, so the same tree could report different churn (and different
|
|
48
|
+
findings) run to run. A dogfood run against a large open-source app fell
|
|
49
|
+
from 3m39s to under 9 seconds.
|
|
50
|
+
- A bare reference to a Ruby core constant (`String`, `Regexp`, `File`, …)
|
|
51
|
+
no longer couples to whichever package defines a namespaced namesake such
|
|
52
|
+
as `Sql::Nodes::Regexp` — Ruby would resolve it to the core class, so
|
|
53
|
+
hashira now drops the edge. The registry answers these through `rooted`,
|
|
54
|
+
which skips the shorthand tails: a lexical namesake still shadows the core
|
|
55
|
+
name as Ruby's own lookup does, and a project that reopens the class at
|
|
56
|
+
top level still owns it. Dogfooding against a large open-source library,
|
|
57
|
+
this deleted a phantom `mixed_audience` finding built entirely on `Array`,
|
|
58
|
+
`String`, and `File`.
|
|
59
|
+
- Constants assigned in a class body (`Node = Struct.new(:path)`) now join the
|
|
60
|
+
census as definitions, so a bare reference resolves to the local constant
|
|
61
|
+
instead of a foreign package's namesake — a karat run had minted two phantom
|
|
62
|
+
edges this way. For usage counts they still collapse into their enclosing
|
|
63
|
+
type: `wide_edge` measures classes, not the constants they carry.
|
|
64
|
+
|
|
8
65
|
## [0.5.0] - 2026-08-04
|
|
9
66
|
|
|
10
67
|
### Added
|
|
@@ -266,6 +323,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
266
323
|
- Output formats: text, JSON, Graphviz dot, Mermaid (`--format`, `--json`).
|
|
267
324
|
- `--help` and `--version`.
|
|
268
325
|
|
|
326
|
+
[0.5.1]: https://github.com/giacope/hashira/releases/tag/v0.5.1
|
|
269
327
|
[0.5.0]: https://github.com/giacope/hashira/releases/tag/v0.5.0
|
|
270
328
|
[0.4.0]: https://github.com/giacope/hashira/releases/tag/v0.4.0
|
|
271
329
|
[0.3.0]: https://github.com/giacope/hashira/releases/tag/v0.3.0
|
data/README.md
CHANGED
|
@@ -26,11 +26,10 @@ package TC Ca Ce I Cyc
|
|
|
26
26
|
billing 1 1 1 0.50 YES
|
|
27
27
|
shipping 1 1 1 0.50 YES
|
|
28
28
|
|
|
29
|
-
Findings (
|
|
30
|
-
cycle: billing can reach itself: billing -> shipping -> billing — any change
|
|
31
|
-
|
|
32
|
-
·
|
|
33
|
-
· shipping/rate.rb:3: Billing::Client
|
|
29
|
+
Findings (1):
|
|
30
|
+
cycle: billing can reach itself: billing -> shipping -> billing — any change may ripple back around. The lightest edge on this cycle is billing -> shipping (1 ref).
|
|
31
|
+
· billing/client.rb:5: Shipping::Rate
|
|
32
|
+
· shipping/rate.rb:8: Billing::Client
|
|
34
33
|
```
|
|
35
34
|
|
|
36
35
|
A healthy project reports `Findings (0): none ✓ — structure is healthy`.
|
|
@@ -295,7 +294,7 @@ it does inside Ruby:
|
|
|
295
294
|
## Code smells
|
|
296
295
|
|
|
297
296
|
RuboCop counts lines and branches inside one method; design smells are about how
|
|
298
|
-
objects treat each other, and no line count sees that. hashira ships the
|
|
297
|
+
objects treat each other, and no line count sees that. hashira ships the twelve
|
|
299
298
|
smells that carry that design signal — the object-relationship kinds, not the
|
|
300
299
|
naming, size, and style checks a linter already argues about — read from the
|
|
301
300
|
same parse trees the other analyzers already built:
|
|
@@ -311,7 +310,16 @@ Findings (2):
|
|
|
311
310
|
What each one catches:
|
|
312
311
|
|
|
313
312
|
- **feature_envy** — a method refers to another object more than to itself; the
|
|
314
|
-
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.
|
|
315
323
|
- **utility_function** — a public instance method that touches no instance state;
|
|
316
324
|
it isn't really a method of this class. Private stateless helpers are fine, and
|
|
317
325
|
`module_function` modules are exempt — that's what they're for.
|
|
@@ -335,7 +343,7 @@ What each one catches:
|
|
|
335
343
|
cheapest type there is.
|
|
336
344
|
|
|
337
345
|
Smell findings gate and ratchet like every other kind — `--fail-on smells` covers
|
|
338
|
-
all
|
|
346
|
+
all twelve, or name one (`--fail-on feature_envy`); `--skip smells` drops the
|
|
339
347
|
analyzer entirely.
|
|
340
348
|
|
|
341
349
|
## Hotspots
|
|
@@ -33,9 +33,13 @@ module Hashira
|
|
|
33
33
|
roots.include?(segments.first(1)) ? segments : (stack.last || []) + segments
|
|
34
34
|
end
|
|
35
35
|
|
|
36
|
-
def direct(type_node)
|
|
36
|
+
def direct(type_node) = statements(type_node).grep(Prism::DefNode)
|
|
37
|
+
|
|
38
|
+
def constants(type_node) = statements(type_node).grep(Prism::ConstantWriteNode)
|
|
39
|
+
|
|
40
|
+
def statements(type_node)
|
|
37
41
|
body = type_node.body
|
|
38
|
-
|
|
42
|
+
body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
39
43
|
end
|
|
40
44
|
end
|
|
41
45
|
end
|
data/lib/hashira/churn.rb
CHANGED
|
@@ -1,47 +1,30 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::CLI::CommandLine
|
|
4
|
-
DEFAULT_BASELINE = "hashira_baseline.json"
|
|
5
|
-
|
|
6
|
-
FORMATS = %w[text json dot mermaid].freeze
|
|
7
|
-
|
|
8
|
-
CI_FLAGS = { "--update-baseline" => :update, "--ratchet" => :ratchet }.freeze
|
|
9
|
-
|
|
10
4
|
def initialize(argv)
|
|
11
5
|
@arguments = Hashira::CLI::Arguments.new(argv)
|
|
12
6
|
end
|
|
13
7
|
|
|
14
|
-
def options =
|
|
8
|
+
def options = page || parsed
|
|
15
9
|
|
|
16
10
|
private
|
|
17
11
|
|
|
18
|
-
def
|
|
19
|
-
|
|
20
|
-
|
|
12
|
+
def page
|
|
13
|
+
flag = Hashira::CLI::FLAGS.select(&:page?).find { seen?(it) }
|
|
14
|
+
Hashira::CLI::Options.page(flag.mode) if flag
|
|
21
15
|
end
|
|
22
16
|
|
|
23
|
-
def
|
|
24
|
-
Hashira::CLI::Options.new(directories: [], mode:, baseline: nil, fail_on: [], skip: [], packaging: :auto)
|
|
25
|
-
end
|
|
17
|
+
def seen?(flag) = flag.names.any? { @arguments.delete(it) }
|
|
26
18
|
|
|
27
19
|
def parsed
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
Hashira::CLI::Options.new(directories: @arguments.rest, **values)
|
|
20
|
+
parses = Hashira::CLI::FLAGS.reject(&:page?).to_h { [it, it.read(@arguments)] }
|
|
21
|
+
Hashira::CLI::Options.new(directories: @arguments.rest, mode: mode(parses), **fields(parses))
|
|
31
22
|
end
|
|
32
23
|
|
|
33
|
-
def
|
|
34
|
-
{
|
|
35
|
-
skip: Hashira::CLI::Skip.parse(take("--skip", "")),
|
|
36
|
-
fail_on: Hashira::CLI::FailOn.parse(take("--fail-on", "")),
|
|
37
|
-
baseline: take("--baseline", DEFAULT_BASELINE), packaging: grouping
|
|
38
|
-
}
|
|
39
|
-
end
|
|
40
|
-
|
|
41
|
-
def grouping = Hashira::CLI::PackageBy.parse(take("--package-by", ""))
|
|
24
|
+
def fields(parses) = parses.to_h { |flag, value| [flag.field, value] }.except(nil)
|
|
42
25
|
|
|
43
|
-
def mode(
|
|
44
|
-
case (asked =
|
|
26
|
+
def mode(parses)
|
|
27
|
+
case (asked = parses.filter_map { |flag, value| flag.bid(value) }.uniq(&:last))
|
|
45
28
|
in [] then :text
|
|
46
29
|
in [[_flag, mode]] then mode
|
|
47
30
|
else conflict(asked)
|
|
@@ -51,26 +34,4 @@ class Hashira::CLI::CommandLine
|
|
|
51
34
|
def conflict(asked)
|
|
52
35
|
raise(Hashira::Error, "conflicting options: #{asked.map(&:first).join(" and ")}")
|
|
53
36
|
end
|
|
54
|
-
|
|
55
|
-
def requested(fail_on)
|
|
56
|
-
CI_FLAGS.filter_map { |flag, mode| [flag, mode] if delete(flag) } + gate(fail_on) + formats
|
|
57
|
-
end
|
|
58
|
-
|
|
59
|
-
def gate(fail_on) = fail_on.empty? ? [] : [["--fail-on", :fail_on]]
|
|
60
|
-
|
|
61
|
-
def formats
|
|
62
|
-
chosen = format
|
|
63
|
-
modes = chosen.empty? ? [] : [["--format #{chosen}", chosen.to_sym]]
|
|
64
|
-
delete("--json") ? modes + [["--json", :json]] : modes
|
|
65
|
-
end
|
|
66
|
-
|
|
67
|
-
def format
|
|
68
|
-
wanted = take("--format", "")
|
|
69
|
-
return wanted if wanted.empty? || FORMATS.include?(wanted)
|
|
70
|
-
raise(Hashira::Error.unknown("--format", wanted, FORMATS))
|
|
71
|
-
end
|
|
72
|
-
|
|
73
|
-
def take(flag, default) = @arguments.take(flag, default)
|
|
74
|
-
|
|
75
|
-
def delete(flag) = @arguments.delete(flag)
|
|
76
37
|
end
|
data/lib/hashira/cli/fail_on.rb
CHANGED
|
@@ -3,19 +3,13 @@
|
|
|
3
3
|
require_relative "../pipeline"
|
|
4
4
|
|
|
5
5
|
module Hashira::CLI::FailOn
|
|
6
|
-
SMELLS = %w[
|
|
7
|
-
control_parameter data_clump duplicate_method_call feature_envy instance_variable_assumption
|
|
8
|
-
manual_dispatch module_initialize nil_check repeated_conditional too_many_instance_variables
|
|
9
|
-
utility_function
|
|
10
|
-
].freeze
|
|
11
|
-
|
|
12
6
|
MEASURES = (Hashira::Pipeline::ANALYZERS - %i[coupling smells]).map(&:to_s).freeze
|
|
13
7
|
|
|
14
8
|
KINDS = {
|
|
15
9
|
"cycles" => "cycle", "sdp" => "sdp_violation", "dupe" => "duplication",
|
|
16
10
|
**Hashira::Pipeline::STRUCTURAL.to_h { [it, it] },
|
|
17
11
|
**MEASURES.to_h { [it, it] },
|
|
18
|
-
"smells" => SMELLS, **SMELLS.to_h { [it, it] }
|
|
12
|
+
"smells" => Hashira::Pipeline::SMELLS, **Hashira::Pipeline::SMELLS.to_h { [it, it] }
|
|
19
13
|
}.freeze
|
|
20
14
|
|
|
21
15
|
module_function
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
class Hashira::CLI
|
|
4
|
+
Flag =
|
|
5
|
+
Data.define(:name, :arg, :default, :field, :parse, :mode, :text) do
|
|
6
|
+
def initialize(**attributes) = super(arg: nil, default: "", field: nil, parse: nil, mode: nil, **attributes)
|
|
7
|
+
|
|
8
|
+
def read(arguments) = arg ? take(arguments) : arguments.delete(name)
|
|
9
|
+
|
|
10
|
+
def take(arguments)
|
|
11
|
+
raw = arguments.take(name, default)
|
|
12
|
+
parse ? parse.parse(raw) : raw
|
|
13
|
+
end
|
|
14
|
+
|
|
15
|
+
def bid(value)
|
|
16
|
+
case [mode, value]
|
|
17
|
+
in [nil, _] | [_, nil] | [_, []] then nil
|
|
18
|
+
in [:parsed, chosen] then ["#{name} #{chosen}", chosen]
|
|
19
|
+
else [name, mode]
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
def label = [name, arg].compact.join(" ")
|
|
24
|
+
|
|
25
|
+
def names = name.split(", ")
|
|
26
|
+
|
|
27
|
+
def page? = Hashira::CLI::Usage::PAGES.include?(mode)
|
|
28
|
+
end
|
|
29
|
+
end
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require_relative "fail_on"
|
|
4
|
+
require_relative "flag"
|
|
5
|
+
require_relative "format"
|
|
6
|
+
require_relative "package_by"
|
|
7
|
+
require_relative "skip"
|
|
8
|
+
|
|
9
|
+
class Hashira::CLI
|
|
10
|
+
FLAGS = [
|
|
11
|
+
Flag.new(
|
|
12
|
+
name: "--format", arg: "FORMAT", parse: Format, mode: :parsed,
|
|
13
|
+
text: ["text (default), json, dot, or mermaid"]
|
|
14
|
+
),
|
|
15
|
+
Flag.new(name: "--json", mode: :json, text: ["shorthand for --format json"]),
|
|
16
|
+
Flag.new(
|
|
17
|
+
name: "--fail-on", arg: "KINDS", field: :fail_on, parse: FailOn, mode: :fail_on,
|
|
18
|
+
text: [
|
|
19
|
+
"exit 1 if findings exist; comma-separated",
|
|
20
|
+
"kinds: cycles, sdp, mixed_audience, wide_edge,",
|
|
21
|
+
"roll_call, complexity, duplication, smells (all",
|
|
22
|
+
"of them), or one smell kind such as feature_envy"
|
|
23
|
+
]
|
|
24
|
+
),
|
|
25
|
+
Flag.new(
|
|
26
|
+
name: "--skip", arg: "ANALYZERS", field: :skip, parse: Skip,
|
|
27
|
+
text: ["drop an analyzer; comma-separated: coupling,", "complexity, duplication, smells"]
|
|
28
|
+
),
|
|
29
|
+
Flag.new(
|
|
30
|
+
name: "--package-by", arg: "WHAT", field: :packaging, parse: PackageBy,
|
|
31
|
+
text: [
|
|
32
|
+
"group coupling by: auto, folder, or namespace",
|
|
33
|
+
"(top-level constant). Default auto: namespace for",
|
|
34
|
+
"Rails apps (config/application.rb in or beside the",
|
|
35
|
+
"analyzed directory), folder otherwise"
|
|
36
|
+
]
|
|
37
|
+
),
|
|
38
|
+
Flag.new(
|
|
39
|
+
name: "--ratchet", mode: :ratchet,
|
|
40
|
+
text: ["fail when edges or findings appear that the", "baseline lacks"]
|
|
41
|
+
),
|
|
42
|
+
Flag.new(name: "--update-baseline", mode: :update, text: ["record the current edges and findings"]),
|
|
43
|
+
Flag.new(
|
|
44
|
+
name: "--baseline", arg: "PATH", field: :baseline, default: "hashira_baseline.json",
|
|
45
|
+
text: ["baseline file (default: hashira_baseline.json)"]
|
|
46
|
+
),
|
|
47
|
+
Flag.new(name: "-h, --help", mode: :help, text: ["print this help"]),
|
|
48
|
+
Flag.new(name: "--version", mode: :version, text: ["print the version"])
|
|
49
|
+
].freeze
|
|
50
|
+
end
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira::CLI::Format
|
|
4
|
+
CHOICES = %w[text json dot mermaid].freeze
|
|
5
|
+
|
|
6
|
+
module_function
|
|
7
|
+
|
|
8
|
+
def parse(value)
|
|
9
|
+
return if value.empty?
|
|
10
|
+
return value.to_sym if CHOICES.include?(value)
|
|
11
|
+
raise(Hashira::Error.unknown("--format", value, CHOICES))
|
|
12
|
+
end
|
|
13
|
+
end
|
data/lib/hashira/cli/options.rb
CHANGED
|
@@ -5,6 +5,8 @@ class Hashira::CLI
|
|
|
5
5
|
Data.define(:directories, :mode, :baseline, :fail_on, :skip, :packaging) do
|
|
6
6
|
def self.parse(argv) = CommandLine.new(argv).options
|
|
7
7
|
|
|
8
|
+
def self.page(mode) = new(directories: [], mode:, baseline: "", fail_on: [], skip: [], packaging: :auto)
|
|
9
|
+
|
|
8
10
|
def pipeline
|
|
9
11
|
Hashira::Pipeline.new(Hashira::Project.detect(directories), enabled: analyzers, packaging:)
|
|
10
12
|
end
|
data/lib/hashira/cli/usage.rb
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "flags"
|
|
4
|
+
|
|
3
5
|
module Hashira::CLI::Usage
|
|
4
|
-
|
|
6
|
+
PAGES = %i[help version].freeze
|
|
7
|
+
|
|
8
|
+
HEADER = <<~TEXT
|
|
5
9
|
Usage: hashira [DIRECTORY ...] [options]
|
|
6
10
|
|
|
7
11
|
Coupling, cognitive-complexity, duplication, and code-smell metrics
|
|
@@ -9,38 +13,30 @@ module Hashira::CLI::Usage
|
|
|
9
13
|
With no directory, auto-detects lib/<gem>.
|
|
10
14
|
|
|
11
15
|
Options:
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
kinds: cycles, sdp, mixed_audience, wide_edge,
|
|
16
|
-
roll_call, complexity, duplication, smells (all
|
|
17
|
-
of them), or one smell kind such as feature_envy
|
|
18
|
-
--skip ANALYZERS drop an analyzer; comma-separated: coupling,
|
|
19
|
-
complexity, duplication, smells
|
|
20
|
-
--package-by WHAT group coupling by: auto, folder, or namespace
|
|
21
|
-
(top-level constant). Default auto: namespace for
|
|
22
|
-
Rails apps (config/application.rb in or beside the
|
|
23
|
-
analyzed directory), folder otherwise
|
|
24
|
-
--ratchet fail when edges or findings appear that the
|
|
25
|
-
baseline lacks
|
|
26
|
-
--update-baseline record the current edges and findings
|
|
27
|
-
--baseline PATH baseline file (default: hashira_baseline.json)
|
|
28
|
-
-h, --help print this help
|
|
29
|
-
--version print the version
|
|
16
|
+
TEXT
|
|
17
|
+
|
|
18
|
+
FOOTER = <<~TEXT
|
|
30
19
|
|
|
31
20
|
Findings accepted by design can be recorded in the baseline as
|
|
32
21
|
"accepted": [{"kind": "...", "package": "...", "reason": "..."}]
|
|
33
22
|
— they leave reports and gates, keeping a one-line reminder each.
|
|
34
23
|
Clones are named by "digest" instead of "package", so an acceptance
|
|
35
24
|
survives the lines above it moving. Read digests from --json.
|
|
36
|
-
|
|
25
|
+
TEXT
|
|
37
26
|
|
|
38
27
|
module_function
|
|
39
28
|
|
|
40
|
-
def help = emit(
|
|
29
|
+
def help = emit(HEADER + options + FOOTER)
|
|
41
30
|
|
|
42
31
|
def version = emit("hashira #{Hashira::VERSION}")
|
|
43
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
|
+
|
|
44
40
|
def emit(output)
|
|
45
41
|
puts(output)
|
|
46
42
|
0
|
data/lib/hashira/cli.rb
CHANGED
|
@@ -1,12 +1,14 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Complexity::MethodFinding
|
|
4
|
+
KIND = "complexity"
|
|
5
|
+
|
|
4
6
|
def initialize(score)
|
|
5
7
|
@score = score
|
|
6
8
|
end
|
|
7
9
|
|
|
8
10
|
def to_finding
|
|
9
|
-
Hashira::Analysis::Finding.new(kind:
|
|
11
|
+
Hashira::Analysis::Finding.new(kind: KIND, package: @score.subject, detail:, evidence:)
|
|
10
12
|
end
|
|
11
13
|
|
|
12
14
|
private
|
|
@@ -38,7 +38,7 @@ class Hashira::Coupling::Census
|
|
|
38
38
|
|
|
39
39
|
private
|
|
40
40
|
|
|
41
|
-
def type?(segments) = @roster.
|
|
41
|
+
def type?(segments) = @roster.type?(segments)
|
|
42
42
|
|
|
43
43
|
def tally
|
|
44
44
|
Hashira::Coupling::Roster.new(@placement.placed.map { |definition, package| [definition, translate(package)] })
|
|
@@ -16,10 +16,9 @@ class Hashira::Coupling::ConstantRegistry
|
|
|
16
16
|
(1...path.length).each { claim(@shorthand, path.drop(it), package) }
|
|
17
17
|
end
|
|
18
18
|
|
|
19
|
-
def package(path)
|
|
20
|
-
|
|
21
|
-
|
|
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,6 +31,8 @@ class Hashira::Coupling::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]
|
|
@@ -16,7 +16,11 @@ module Hashira
|
|
|
16
16
|
|
|
17
17
|
def superclass = node.superclass
|
|
18
18
|
|
|
19
|
-
def
|
|
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
|
|
@@ -26,6 +26,9 @@ class Hashira::Coupling::Definitions
|
|
|
26
26
|
|
|
27
27
|
def scan(file, tree)
|
|
28
28
|
package = @project.package(file)
|
|
29
|
-
Hashira::Analysis::TypeWalk.each(tree, roots: roots)
|
|
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
|
|
@@ -4,6 +4,7 @@ class Hashira::Coupling::Roster
|
|
|
4
4
|
def initialize(placed)
|
|
5
5
|
@registry = Hashira::Coupling::ConstantRegistry.new
|
|
6
6
|
@types = Hash.new(0)
|
|
7
|
+
@typed = Set.new
|
|
7
8
|
counted = Set.new
|
|
8
9
|
placed.each { |definition, package| admit(definition, package, counted) }
|
|
9
10
|
end
|
|
@@ -12,6 +13,8 @@ class Hashira::Coupling::Roster
|
|
|
12
13
|
|
|
13
14
|
def origins = @registry.origins
|
|
14
15
|
|
|
16
|
+
def type?(path) = @typed.include?(path)
|
|
17
|
+
|
|
15
18
|
def packages = @types.keys | @registry.packages
|
|
16
19
|
|
|
17
20
|
private
|
|
@@ -20,6 +23,7 @@ class Hashira::Coupling::Roster
|
|
|
20
23
|
return unless package
|
|
21
24
|
path = definition.path
|
|
22
25
|
@registry.register(path, package)
|
|
26
|
+
@typed << path if definition.type?
|
|
23
27
|
@types[package] += 1 if definition.counted? && counted.add?(path)
|
|
24
28
|
end
|
|
25
29
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Coupling::Scope
|
|
4
|
+
CORE = Object.constants.select { Object.const_source_location(it) == [] }.to_set(&:to_s).freeze
|
|
5
|
+
|
|
4
6
|
def initialize(registry, catalog, placement)
|
|
5
7
|
@registry = registry
|
|
6
8
|
@catalog = catalog
|
|
@@ -10,7 +12,7 @@ class Hashira::Coupling::Scope
|
|
|
10
12
|
def resolve(segments, nesting)
|
|
11
13
|
return if @placement.skip?(segments)
|
|
12
14
|
scoped = nesting.reverse_each.filter_map { descend(it, segments) }.first
|
|
13
|
-
scoped ? qualify(scoped) :
|
|
15
|
+
scoped ? qualify(scoped) : outward(@catalog.strip(segments))
|
|
14
16
|
end
|
|
15
17
|
|
|
16
18
|
def pinpoint(segments)
|
|
@@ -20,6 +22,8 @@ class Hashira::Coupling::Scope
|
|
|
20
22
|
|
|
21
23
|
private
|
|
22
24
|
|
|
25
|
+
def outward(path) = CORE.include?(path.first) ? @registry.rooted(path) : @registry.package(path)
|
|
26
|
+
|
|
23
27
|
def descend(entry, segments)
|
|
24
28
|
segments.length.downto(1).filter_map { @registry.exact(@catalog.strip(entry + segments.first(it))) }.first
|
|
25
29
|
end
|
|
@@ -1,6 +1,8 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Duplication::DuplicationFinding
|
|
4
|
+
KIND = "duplication"
|
|
5
|
+
|
|
4
6
|
def initialize(cluster, churn)
|
|
5
7
|
@cluster = cluster
|
|
6
8
|
@churn = churn
|
|
@@ -8,7 +10,7 @@ class Hashira::Duplication::DuplicationFinding
|
|
|
8
10
|
|
|
9
11
|
def to_finding
|
|
10
12
|
site = @cluster.canonical
|
|
11
|
-
Hashira::Analysis::Finding.new(kind:
|
|
13
|
+
Hashira::Analysis::Finding.new(kind: KIND, package: site.location, digest: site.digest, detail:, evidence:)
|
|
12
14
|
end
|
|
13
15
|
|
|
14
16
|
private
|
data/lib/hashira/pipeline.rb
CHANGED
|
@@ -2,12 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
require "prism"
|
|
4
4
|
require_relative "coupling/report"
|
|
5
|
+
require_relative "smells/report"
|
|
5
6
|
|
|
6
7
|
class Hashira::Pipeline
|
|
7
8
|
ANALYZERS = %i[coupling complexity duplication smells].freeze
|
|
8
9
|
|
|
9
10
|
STRUCTURAL = Hashira::Coupling::Report::RULES.map { it::KIND }.freeze
|
|
10
11
|
|
|
12
|
+
SMELLS = (
|
|
13
|
+
Hashira::Smells::Report::CHECKS.map(&:kind) + [Hashira::Smells::BoundarySprawl::KIND]
|
|
14
|
+
).sort.freeze
|
|
15
|
+
|
|
11
16
|
def initialize(project, enabled: ANALYZERS, packaging: :auto)
|
|
12
17
|
@project = project
|
|
13
18
|
@enabled = enabled
|
|
@@ -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
|
data/lib/hashira/smells/check.rb
CHANGED
|
@@ -1,6 +1,10 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
3
|
class Hashira::Smells::Check
|
|
4
|
+
def self.kind = name.split("::").last.gsub(/(?<=[a-z])(?=[A-Z])/, "_").downcase
|
|
5
|
+
|
|
6
|
+
def self.judge? = false
|
|
7
|
+
|
|
4
8
|
def initialize(subject)
|
|
5
9
|
@subject = subject
|
|
6
10
|
end
|
|
@@ -14,7 +18,7 @@ class Hashira::Smells::Check
|
|
|
14
18
|
|
|
15
19
|
attr_reader :subject
|
|
16
20
|
|
|
17
|
-
def kind = self.class.
|
|
21
|
+
def kind = self.class.kind
|
|
18
22
|
|
|
19
23
|
def label = subject.subject
|
|
20
24
|
|
|
@@ -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,31 +1,42 @@
|
|
|
1
1
|
# frozen_string_literal: true
|
|
2
2
|
|
|
3
|
+
require_relative "boundary_sprawl"
|
|
4
|
+
require_relative "check"
|
|
5
|
+
require_relative "control_parameter"
|
|
6
|
+
require_relative "data_clump"
|
|
7
|
+
require_relative "duplicate_method_call"
|
|
8
|
+
require_relative "feature_envy"
|
|
9
|
+
require_relative "foreign"
|
|
10
|
+
require_relative "instance_variable_assumption"
|
|
11
|
+
require_relative "manual_dispatch"
|
|
12
|
+
require_relative "module_initialize"
|
|
13
|
+
require_relative "nil_check"
|
|
14
|
+
require_relative "ownership"
|
|
15
|
+
require_relative "repeated_conditional"
|
|
16
|
+
require_relative "too_many_instance_variables"
|
|
17
|
+
require_relative "utility_function"
|
|
18
|
+
|
|
3
19
|
class Hashira::Smells::Report
|
|
20
|
+
CHECKS = Hashira::Smells::Check.subclasses.sort_by(&:name).freeze
|
|
21
|
+
|
|
22
|
+
JUDGES = CHECKS.select(&:judge?).freeze
|
|
23
|
+
|
|
24
|
+
PROBES = CHECKS.reject(&:judge?).freeze
|
|
25
|
+
|
|
4
26
|
def initialize(project, trees)
|
|
5
|
-
@
|
|
27
|
+
@census = Hashira::Smells::Census.new(project, trees)
|
|
28
|
+
@types = @census.types
|
|
6
29
|
end
|
|
7
30
|
|
|
8
|
-
def findings = @findings ||= sniff(@types,
|
|
31
|
+
def findings = @findings ||= sniff(@types, JUDGES) + sniff(methods, PROBES) + sprawl
|
|
9
32
|
|
|
10
33
|
private
|
|
11
34
|
|
|
12
|
-
def
|
|
35
|
+
def methods = @types.flat_map(&:defs)
|
|
13
36
|
|
|
14
|
-
def
|
|
37
|
+
def sprawl = Hashira::Smells::BoundarySprawl.new(methods, @census.ownership).findings
|
|
15
38
|
|
|
16
|
-
def
|
|
17
|
-
[
|
|
18
|
-
Hashira::Smells::DataClump, Hashira::Smells::InstanceVariableAssumption,
|
|
19
|
-
Hashira::Smells::ModuleInitialize, Hashira::Smells::RepeatedConditional,
|
|
20
|
-
Hashira::Smells::TooManyInstanceVariables
|
|
21
|
-
]
|
|
22
|
-
end
|
|
39
|
+
def sniff(subjects, checks) = subjects.flat_map { |subject| verdicts(subject, checks) }
|
|
23
40
|
|
|
24
|
-
def
|
|
25
|
-
[
|
|
26
|
-
Hashira::Smells::ControlParameter, Hashira::Smells::DuplicateMethodCall,
|
|
27
|
-
Hashira::Smells::FeatureEnvy, Hashira::Smells::ManualDispatch,
|
|
28
|
-
Hashira::Smells::NilCheck, Hashira::Smells::UtilityFunction
|
|
29
|
-
]
|
|
30
|
-
end
|
|
41
|
+
def verdicts(subject, checks) = checks.filter_map { |check| check.new(subject).finding }
|
|
31
42
|
end
|
|
@@ -10,6 +10,8 @@ class Hashira::Smells::TooManyInstanceVariables < Hashira::Smells::Check
|
|
|
10
10
|
Prism::InstanceVariableOperatorWriteNode, Prism::InstanceVariableTargetNode
|
|
11
11
|
].freeze
|
|
12
12
|
|
|
13
|
+
def self.judge? = true
|
|
14
|
+
|
|
13
15
|
private
|
|
14
16
|
|
|
15
17
|
def smelly? = subject.kind == :class && names.size > LIMIT
|
data/lib/hashira/version.rb
CHANGED
data/lib/hashira.rb
CHANGED
|
@@ -47,6 +47,9 @@ require_relative "hashira/cli"
|
|
|
47
47
|
require_relative "hashira/cli/arguments"
|
|
48
48
|
require_relative "hashira/cli/command_line"
|
|
49
49
|
require_relative "hashira/cli/fail_on"
|
|
50
|
+
require_relative "hashira/cli/flag"
|
|
51
|
+
require_relative "hashira/cli/flags"
|
|
52
|
+
require_relative "hashira/cli/format"
|
|
50
53
|
require_relative "hashira/cli/options"
|
|
51
54
|
require_relative "hashira/cli/package_by"
|
|
52
55
|
require_relative "hashira/cli/run"
|
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.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,
|
|
14
|
-
from the AST, then ranks files by cost against git churn. Findings
|
|
15
|
-
file-level evidence, and a baseline ratchets edges and findings for
|
|
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:
|
|
@@ -44,6 +44,9 @@ files:
|
|
|
44
44
|
- lib/hashira/cli/arguments.rb
|
|
45
45
|
- lib/hashira/cli/command_line.rb
|
|
46
46
|
- lib/hashira/cli/fail_on.rb
|
|
47
|
+
- lib/hashira/cli/flag.rb
|
|
48
|
+
- lib/hashira/cli/flags.rb
|
|
49
|
+
- lib/hashira/cli/format.rb
|
|
47
50
|
- lib/hashira/cli/options.rb
|
|
48
51
|
- lib/hashira/cli/package_by.rb
|
|
49
52
|
- lib/hashira/cli/run.rb
|
|
@@ -123,6 +126,7 @@ files:
|
|
|
123
126
|
- lib/hashira/report/smell_phrases.rb
|
|
124
127
|
- lib/hashira/report/text.rb
|
|
125
128
|
- lib/hashira/report/view.rb
|
|
129
|
+
- lib/hashira/smells/boundary_sprawl.rb
|
|
126
130
|
- lib/hashira/smells/census.rb
|
|
127
131
|
- lib/hashira/smells/check.rb
|
|
128
132
|
- lib/hashira/smells/conditions.rb
|
|
@@ -131,10 +135,12 @@ files:
|
|
|
131
135
|
- lib/hashira/smells/data_clump.rb
|
|
132
136
|
- lib/hashira/smells/duplicate_method_call.rb
|
|
133
137
|
- lib/hashira/smells/feature_envy.rb
|
|
138
|
+
- lib/hashira/smells/foreign.rb
|
|
134
139
|
- lib/hashira/smells/instance_variable_assumption.rb
|
|
135
140
|
- lib/hashira/smells/manual_dispatch.rb
|
|
136
141
|
- lib/hashira/smells/module_initialize.rb
|
|
137
142
|
- lib/hashira/smells/nil_check.rb
|
|
143
|
+
- lib/hashira/smells/ownership.rb
|
|
138
144
|
- lib/hashira/smells/param_check.rb
|
|
139
145
|
- lib/hashira/smells/refs.rb
|
|
140
146
|
- lib/hashira/smells/repeated_conditional.rb
|
|
@@ -168,5 +174,6 @@ required_rubygems_version: !ruby/object:Gem::Requirement
|
|
|
168
174
|
requirements: []
|
|
169
175
|
rubygems_version: 4.0.17
|
|
170
176
|
specification_version: 4
|
|
171
|
-
summary: Coupling, cognitive-complexity, and
|
|
177
|
+
summary: Coupling, cognitive-complexity, duplication and code-smell metrics for Ruby,
|
|
178
|
+
via Prism
|
|
172
179
|
test_files: []
|