hashira 0.5.0 → 0.5.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 574b35721d888046d7d8bbe02129896ebb6305aca89c30127c5372cb855bff74
4
- data.tar.gz: ecd9eae0bf5a0cb5baf9b7637401ce0e12afdebcaa7e61e1c3f58b155e16a7bc
3
+ metadata.gz: 698031b3fff8e705bf80379be16ee05ecacfa9c137459ab8beb855cee1c624dd
4
+ data.tar.gz: c50d6bc7ee142f665c8764d56d7fdf07de381b00529715e37682aed6f0f1856d
5
5
  SHA512:
6
- metadata.gz: 5b08c0cb5f567948aa66f6e1e69d5c610b4eeb75811940b7d833e3c08d279372d5d27100c1efa3f0b3917e0f9fbd32d675e7284039e57086044c8e26e7edb509
7
- data.tar.gz: 10a1128b98b097b168adbaca6b2c0408a712d992725f5c41d1146fcc43cf975511cbc9dd4dd31a3bc3bfc42b2279906859233f66cde4eada83d96f20f3449aae
6
+ metadata.gz: 4ce8b38adba57e0d1bdf6343362efb3e205c6455d5634bf3cd07c2b429870423268fc4263215ad3ea767284a7acc906901259acbbec7570d386db7fc0759626b
7
+ data.tar.gz: 3670d8b067f2535d745c3785d52087f2aa189909511566e8452818da408e82882077fe428a72b16470640f60bddcdcbd0fbc6109fc412d64652648c20b1bcb9c
data/CHANGELOG.md CHANGED
@@ -5,6 +5,33 @@ 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.5.1] - 2026-08-05
9
+
10
+ ### Fixed
11
+
12
+ - The churn scan passes `--no-renames` to `git log`, counting a move as a
13
+ delete plus an add. Rename detection needs blob contents, so on a partial
14
+ clone (`--filter=blob:none`) the old command lazy-fetched objects from the
15
+ network one at a time — on a long history the scan stalled for minutes and
16
+ looked like a hang. It also made the tally depend on which blobs git could
17
+ see, so the same tree could report different churn (and different
18
+ findings) run to run. A dogfood run against a large open-source app fell
19
+ from 3m39s to under 9 seconds.
20
+ - A bare reference to a Ruby core constant (`String`, `Regexp`, `File`, …)
21
+ no longer couples to whichever package defines a namespaced namesake such
22
+ as `Sql::Nodes::Regexp` — Ruby would resolve it to the core class, so
23
+ hashira now drops the edge. The registry answers these through `rooted`,
24
+ which skips the shorthand tails: a lexical namesake still shadows the core
25
+ name as Ruby's own lookup does, and a project that reopens the class at
26
+ top level still owns it. Dogfooding against a large open-source library,
27
+ this deleted a phantom `mixed_audience` finding built entirely on `Array`,
28
+ `String`, and `File`.
29
+ - Constants assigned in a class body (`Node = Struct.new(:path)`) now join the
30
+ census as definitions, so a bare reference resolves to the local constant
31
+ instead of a foreign package's namesake — a karat run had minted two phantom
32
+ edges this way. For usage counts they still collapse into their enclosing
33
+ type: `wide_edge` measures classes, not the constants they carry.
34
+
8
35
  ## [0.5.0] - 2026-08-04
9
36
 
10
37
  ### Added
@@ -266,6 +293,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
266
293
  - Output formats: text, JSON, Graphviz dot, Mermaid (`--format`, `--json`).
267
294
  - `--help` and `--version`.
268
295
 
296
+ [0.5.1]: https://github.com/giacope/hashira/releases/tag/v0.5.1
269
297
  [0.5.0]: https://github.com/giacope/hashira/releases/tag/v0.5.0
270
298
  [0.4.0]: https://github.com/giacope/hashira/releases/tag/v0.4.0
271
299
  [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`.
@@ -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,15 @@
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 = Hashira::Smells::Report::CHECKS.map(&:kind).sort.freeze
13
+
11
14
  def initialize(project, enabled: ANALYZERS, packaging: :auto)
12
15
  @project = project
13
16
  @enabled = enabled
@@ -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
 
@@ -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?
@@ -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
@@ -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,34 @@
1
1
  # frozen_string_literal: true
2
2
 
3
+ require_relative "check"
4
+ require_relative "control_parameter"
5
+ require_relative "data_clump"
6
+ require_relative "duplicate_method_call"
7
+ require_relative "feature_envy"
8
+ require_relative "instance_variable_assumption"
9
+ require_relative "manual_dispatch"
10
+ require_relative "module_initialize"
11
+ require_relative "nil_check"
12
+ require_relative "repeated_conditional"
13
+ require_relative "too_many_instance_variables"
14
+ require_relative "utility_function"
15
+
3
16
  class Hashira::Smells::Report
17
+ CHECKS = Hashira::Smells::Check.subclasses.sort_by(&:name).freeze
18
+
19
+ JUDGES = CHECKS.select(&:judge?).freeze
20
+
21
+ PROBES = CHECKS.reject(&:judge?).freeze
22
+
4
23
  def initialize(project, trees)
5
24
  @types = Hashira::Smells::Census.new(project, trees).types
6
25
  end
7
26
 
8
- def findings = @findings ||= sniff(@types, judges) + sniff(@types.flat_map(&:defs), probes)
27
+ def findings = @findings ||= sniff(@types, JUDGES) + sniff(@types.flat_map(&:defs), PROBES)
9
28
 
10
29
  private
11
30
 
12
31
  def sniff(subjects, checks) = subjects.flat_map { |subject| verdicts(subject, checks) }
13
32
 
14
33
  def verdicts(subject, checks) = checks.filter_map { |check| check.new(subject).finding }
15
-
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
23
-
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
31
34
  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.5.1"
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.5.1
5
5
  platform: ruby
6
6
  authors:
7
7
  - Giacomo GK
@@ -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