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.
Files changed (39) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +58 -0
  3. data/README.md +16 -8
  4. data/lib/hashira/analysis/syntax.rb +6 -2
  5. data/lib/hashira/churn.rb +1 -1
  6. data/lib/hashira/cli/command_line.rb +10 -49
  7. data/lib/hashira/cli/fail_on.rb +1 -7
  8. data/lib/hashira/cli/flag.rb +29 -0
  9. data/lib/hashira/cli/flags.rb +50 -0
  10. data/lib/hashira/cli/format.rb +13 -0
  11. data/lib/hashira/cli/options.rb +2 -0
  12. data/lib/hashira/cli/usage.rb +17 -21
  13. data/lib/hashira/cli.rb +1 -1
  14. data/lib/hashira/complexity/method_finding.rb +3 -1
  15. data/lib/hashira/coupling/census.rb +1 -1
  16. data/lib/hashira/coupling/constant_registry.rb +5 -4
  17. data/lib/hashira/coupling/definition.rb +5 -1
  18. data/lib/hashira/coupling/definitions.rb +4 -1
  19. data/lib/hashira/coupling/roster.rb +4 -0
  20. data/lib/hashira/coupling/scope.rb +5 -1
  21. data/lib/hashira/duplication/duplication_finding.rb +3 -1
  22. data/lib/hashira/pipeline.rb +5 -0
  23. data/lib/hashira/report/smell_phrases.rb +6 -0
  24. data/lib/hashira/smells/boundary_sprawl.rb +43 -0
  25. data/lib/hashira/smells/census.rb +4 -1
  26. data/lib/hashira/smells/check.rb +5 -1
  27. data/lib/hashira/smells/contexts.rb +1 -1
  28. data/lib/hashira/smells/data_clump.rb +2 -0
  29. data/lib/hashira/smells/feature_envy.rb +3 -1
  30. data/lib/hashira/smells/foreign.rb +131 -0
  31. data/lib/hashira/smells/instance_variable_assumption.rb +2 -0
  32. data/lib/hashira/smells/module_initialize.rb +2 -0
  33. data/lib/hashira/smells/ownership.rb +55 -0
  34. data/lib/hashira/smells/repeated_conditional.rb +2 -0
  35. data/lib/hashira/smells/report.rb +29 -18
  36. data/lib/hashira/smells/too_many_instance_variables.rb +2 -0
  37. data/lib/hashira/version.rb +1 -1
  38. data/lib/hashira.rb +3 -0
  39. metadata +13 -6
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 574b35721d888046d7d8bbe02129896ebb6305aca89c30127c5372cb855bff74
4
- data.tar.gz: ecd9eae0bf5a0cb5baf9b7637401ce0e12afdebcaa7e61e1c3f58b155e16a7bc
3
+ metadata.gz: a0b871ab3e0bf096a2752a48a4c3776d2dbe2ee0cae33b2df8af89649d0952ca
4
+ data.tar.gz: b1058810a8d965e1010044691d1b11571f21b077581f5e435d40b0d263f133b9
5
5
  SHA512:
6
- metadata.gz: 5b08c0cb5f567948aa66f6e1e69d5c610b4eeb75811940b7d833e3c08d279372d5d27100c1efa3f0b3917e0f9fbd32d675e7284039e57086044c8e26e7edb509
7
- data.tar.gz: 10a1128b98b097b168adbaca6b2c0408a712d992725f5c41d1146fcc43cf975511cbc9dd4dd31a3bc3bfc42b2279906859233f66cde4eada83d96f20f3449aae
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 (2):
30
- cycle: billing can reach itself: billing -> shipping -> billing — any change
31
- may ripple back around. The lightest edge on this cycle is billing -> shipping (1 ref).
32
- · billing/client.rb:3: Shipping::Rate
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 eleven
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 eleven, or name one (`--fail-on feature_envy`); `--skip smells` drops the
346
+ all twelve, or name one (`--fail-on feature_envy`); `--skip smells` drops the
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
- (body.is_a?(Prism::StatementsNode) ? body.body : [body]).grep(Prism::DefNode)
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,7 +1,7 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Churn
4
- LOG = "git log --name-only --format= 2>/dev/null"
4
+ LOG = "git log --no-renames --name-only --format= 2>/dev/null"
5
5
  SITES_THAT_DRIFT_APART = 2
6
6
 
7
7
  def self.scan = new(tally(`#{LOG}`))
@@ -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 = shortcuts || parsed
8
+ def options = page || parsed
15
9
 
16
10
  private
17
11
 
18
- def shortcuts
19
- return usage(:help) if delete("--help") || delete("-h")
20
- usage(:version) if delete("--version")
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 usage(mode)
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
- values = flags
29
- values[:mode] = mode(values[:fail_on])
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 flags
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(fail_on)
44
- case (asked = requested(fail_on).uniq(&:last))
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
@@ -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
@@ -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
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "flags"
4
+
3
5
  module Hashira::CLI::Usage
4
- TEXT = <<~HELP
6
+ PAGES = %i[help version].freeze
7
+
8
+ HEADER = <<~TEXT
5
9
  Usage: hashira [DIRECTORY ...] [options]
6
10
 
7
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
- --format FORMAT text (default), json, dot, or mermaid
13
- --json shorthand for --format json
14
- --fail-on KINDS exit 1 if findings exist; comma-separated
15
- kinds: cycles, sdp, 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
- HELP
25
+ TEXT
37
26
 
38
27
  module_function
39
28
 
40
- def help = emit(TEXT)
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
@@ -8,7 +8,7 @@ class Hashira::CLI
8
8
  failure(error)
9
9
  end
10
10
 
11
- def self.usage?(options) = %i[help version].include?(options.mode)
11
+ def self.usage?(options) = Usage::PAGES.include?(options.mode)
12
12
 
13
13
  def self.failure(error)
14
14
  warn("hashira: #{error.message}")
@@ -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: "complexity", package: @score.subject, detail:, evidence:)
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.origins.key?(segments.join("::"))
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
- found = anchored(path) || enclosing(path)
21
- found unless found == AMBIGUOUS
22
- end
19
+ def package(path) = settle(anchored(path) || enclosing(path))
20
+
21
+ def rooted(path) = settle(@origins[path.join("::")] || enclosing(path))
23
22
 
24
23
  def exact(path) = @origins[path.join("::")]
25
24
 
@@ -32,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 counted? = klass? || !Hashira::Analysis::Syntax.direct(node).empty?
19
+ def module? = node.is_a?(Prism::ModuleNode)
20
+
21
+ def type? = klass? || module?
22
+
23
+ def counted? = klass? || (module? && !Hashira::Analysis::Syntax.direct(node).empty?)
20
24
  end
21
25
  end
22
26
  end
@@ -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) { |node, full| yield(node, full, package) }
29
+ Hashira::Analysis::TypeWalk.each(tree, roots: roots) do |node, full|
30
+ yield(node, full, package)
31
+ Hashira::Analysis::Syntax.constants(node).each { yield(it, full + [it.name.to_s], package) }
32
+ end
30
33
  end
31
34
  end
@@ -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) : @registry.package(@catalog.strip(segments))
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: "duplication", package: site.location, digest: site.digest, detail:, evidence:)
13
+ Hashira::Analysis::Finding.new(kind: KIND, package: site.location, digest: site.digest, detail:, evidence:)
12
14
  end
13
15
 
14
16
  private
@@ -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
@@ -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.name.split("::").last.gsub(/(?<=[a-z])(?=[A-Z])/, "_").downcase
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
@@ -5,6 +5,8 @@ class Hashira::Smells::DataClump < Hashira::Smells::Check
5
5
 
6
6
  MIN_SIZE = 2
7
7
 
8
+ def self.judge? = true
9
+
8
10
  private
9
11
 
10
12
  def smelly? = clumps.any?
@@ -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
@@ -26,6 +26,8 @@ class Hashira::Smells::InstanceVariableAssumption < Hashira::Smells::Check
26
26
  end
27
27
  end
28
28
 
29
+ def self.judge? = true
30
+
29
31
  private
30
32
 
31
33
  def smelly? = subject.kind == :class && assumed.any?
@@ -1,6 +1,8 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  class Hashira::Smells::ModuleInitialize < Hashira::Smells::Check
4
+ def self.judge? = true
5
+
4
6
  private
5
7
 
6
8
  def smelly? = subject.kind == :module && flagged
@@ -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
@@ -5,6 +5,8 @@ require "prism"
5
5
  class Hashira::Smells::RepeatedConditional < Hashira::Smells::Check
6
6
  LIMIT = 2
7
7
 
8
+ def self.judge? = true
9
+
8
10
  private
9
11
 
10
12
  def smelly? = subject.kind == :class && repeats.any?
@@ -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
- @types = Hashira::Smells::Census.new(project, trees).types
27
+ @census = Hashira::Smells::Census.new(project, trees)
28
+ @types = @census.types
6
29
  end
7
30
 
8
- def findings = @findings ||= sniff(@types, judges) + sniff(@types.flat_map(&:defs), probes)
31
+ def findings = @findings ||= sniff(@types, JUDGES) + sniff(methods, PROBES) + sprawl
9
32
 
10
33
  private
11
34
 
12
- def sniff(subjects, checks) = subjects.flat_map { |subject| verdicts(subject, checks) }
35
+ def methods = @types.flat_map(&:defs)
13
36
 
14
- def verdicts(subject, checks) = checks.filter_map { |check| check.new(subject).finding }
37
+ def sprawl = Hashira::Smells::BoundarySprawl.new(methods, @census.ownership).findings
15
38
 
16
- def judges
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 probes
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
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashira
4
- VERSION = "0.5.0"
4
+ VERSION = "0.6.0"
5
5
  end
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.5.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, and near-miss code duplication in a Ruby codebase
14
- from the AST, then ranks files by cost against git churn. Findings are backed by
15
- file-level evidence, and a baseline ratchets edges and findings for CI — direction,
16
- not a score.
13
+ per-method cognitive complexity, near-miss code duplication, and twelve design smells
14
+ in a Ruby codebase from the AST, then ranks files by cost against git churn. Findings
15
+ are backed by file-level evidence, and a baseline ratchets edges and findings for
16
+ CI — direction, not a score.
17
17
  email:
18
18
  - giaco@hey.com
19
19
  executables:
@@ -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 duplication metrics for Ruby, via Prism
177
+ summary: Coupling, cognitive-complexity, duplication and code-smell metrics for Ruby,
178
+ via Prism
172
179
  test_files: []