hashira 0.2.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 8e51a640b2b2db8106e09f6679c9cd8687b834f8132db254b71ab6eb0c53c75c
4
- data.tar.gz: 3fc2559e8d15081d4d201dcd24496939ceff92ce6c21fe33248af4871e03755c
3
+ metadata.gz: b806d4d5704515b3371235d38cdd4e60a0c37b98194c91922d57a8f5c448ffab
4
+ data.tar.gz: 774766f13fa3b820557cb613e9d9b0c7ffcca2d220c30611562d1467a21f142b
5
5
  SHA512:
6
- metadata.gz: 7c676c8b8764ad0ebab7384a0cd72984efd979853842f3a51c28bb8e079815088f4d3e0fc1569442d4d46371d3cbb700364730cab85f176726c0b40acc6e45ba
7
- data.tar.gz: ea97955b88d6b57c52f980662eec0c0fb690dabac64d47734bf7ad3e94efbb4c18ced3483e8f8afb65833b807aaff248f4bcd37995875a24ea1031c244dc7ccc
6
+ metadata.gz: 8bd5fc6feb807c1255adec14ee18405842a7125dbb4d661c777f85454a831f357484090d90ef39cdc2e1d854df037f61147ede6ef1dfa1854b945bfbb27cd686
7
+ data.tar.gz: c853f3b158e0a1e501aeb48333c793eb92fcf0e51c9dabbdd538e643889dfd36141c1bfacfcd8bbebc03934cb0ec96690508cd7747cea99fb4b3e081b1aca4b9
data/CHANGELOG.md CHANGED
@@ -5,6 +5,49 @@ 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.3.0] - 2026-07-26
9
+
10
+ ### Changed
11
+
12
+ - Package boundaries are found at any depth. Directory detection descends
13
+ single-folder wrapper chains (`lib` → `lib/gem` → `lib/gem/core`), so
14
+ `hashira`, `hashira lib`, and `hashira lib/gem/core` land on the same
15
+ boundaries; descent stops at loose code files. Constant resolution now
16
+ strips the inferred shared namespace *prefix* (majority per level across
17
+ packages) instead of a single root module, so analyzing a nested subtree
18
+ resolves cross-package references instead of silently reporting no edges.
19
+ - With several directories, same-named subfolders no longer merge into one
20
+ package: a contested name is qualified by its directory (`app/models` vs
21
+ `lib/models`); unique names stay short.
22
+ - Constant resolution is path-based. Each definition registers its full
23
+ constant path and its suffixes as shorthand; a sighting resolves by longest
24
+ match, and a name claimed by several packages resolves to nothing rather
25
+ than to the last one parsed. A namespace mirrored across layers
26
+ (`Admin::Account` in `app/models/admin`, `Admin::AccountsController` in
27
+ `app/controllers/admin`) now attributes each reference to the right side —
28
+ a model reaching into its controller layer shows up as an edge (and a
29
+ cycle) instead of vanishing as a self-reference — and a bare reference to
30
+ a name declared in exactly one package (`Skill.all`) now counts.
31
+
32
+ ### Fixed
33
+
34
+ - Duplication: a listing interrupted by a statement of another shape is no
35
+ longer windowed as a clone. The rule applied only when an entire sibling run
36
+ was homogeneous, so one trailing `module` after a block of requires — or a
37
+ `banner =` before a run of `o.on` calls — put the whole list back in scope.
38
+ Listings are now the maximal same-shape stretches within a run, and they are
39
+ opaque: no window reaches into one, so a list row never lends its mass to the
40
+ statements beside it. Two files ending a require block with `module Foo` no
41
+ longer match on the tail of the block, and a genuine clone next to a list is
42
+ weighed on its own size rather than the list's.
43
+ - Duplication: a near-miss neighbour no longer buries the exact clone pair
44
+ inside its cluster. Exact matches and near misses are unioned into one
45
+ cluster, which is then judged as a whole — so a single fuzzy member raised the
46
+ mass floor from 16 to 40 and took the exact pair down with it, and adding a
47
+ third, sloppier copy of a duplicated method made the finding disappear. A
48
+ cluster that misses the raised floor now falls back to its identically shaped
49
+ core and is weighed again on the floor that evidence earns.
50
+
8
51
  ## [0.2.0] - 2026-07-25
9
52
 
10
53
  ### Added
@@ -70,5 +113,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
70
113
  - Output formats: text, JSON, Graphviz dot, Mermaid (`--format`, `--json`).
71
114
  - `--help` and `--version`.
72
115
 
116
+ [0.3.0]: https://github.com/giacope/hashira/releases/tag/v0.3.0
73
117
  [0.2.0]: https://github.com/giacope/hashira/releases/tag/v0.2.0
74
118
  [0.1.0]: https://github.com/giacope/hashira/releases/tag/v0.1.0
data/README.md CHANGED
@@ -65,7 +65,9 @@ Requires Ruby 3.4 or newer.
65
65
 
66
66
  ## Getting started
67
67
 
68
- Point hashira at your code, or run it with no arguments to auto-detect `lib/<gem>`:
68
+ Point hashira at your code, or run it with no arguments to auto-detect `lib/<gem>`.
69
+ Single-folder wrapper chains are descended automatically, so `hashira`,
70
+ `hashira lib`, and `hashira lib/gem/core` land on the same package boundaries:
69
71
 
70
72
  ```sh
71
73
  hashira # auto-detects lib/<gem>
@@ -264,9 +266,14 @@ letter repeated — it tells you nothing about what to open first.
264
266
  **Coupling.** A dependency edge A→B exists when a file in package A references a
265
267
  constant declared by package B. Declarations are read from the AST; strings and
266
268
  comments are invisible. A type counts toward TC only if it defines a method
267
- directly in its body; pure namespace wrappers don't count. The root namespace is
268
- inferred (the most common outermost constant), so `App::Alpha` and `Alpha` resolve
269
- to the same package. Each edge carries a **weight**: the number of constant
269
+ directly in its body; pure namespace wrappers don't count. The namespace prefix
270
+ shared by the packages is inferred (`App`, or `App::Core` when analyzing a nested
271
+ subtree), so `App::Alpha` and `Alpha` resolve to the same package. Resolution is
272
+ by longest constant path, so a namespace mirrored across packages
273
+ (`Admin::Account` in models, `Admin::AccountsController` in controllers) sends
274
+ each reference to the right side; a bare name declared in exactly one package
275
+ resolves there, and a name several packages claim resolves to nothing rather
276
+ than to a guess. Each edge carries a **weight**: the number of constant
270
277
  references backing it. A root-level file `x.rb` folds into package `x` when a
271
278
  sibling folder `x/` exists; everything else at the top level lands in `(root)`.
272
279
 
@@ -6,33 +6,33 @@ module Hashira
6
6
  def initialize(project, trees)
7
7
  @definitions = Definitions.new(project, trees)
8
8
  @type_count = Hash.new(0)
9
- @declaring_package = {}
10
- @root_namespace = RootNamespace.infer(@definitions)
9
+ @registry = ConstantRegistry.new
10
+ @namespace_prefix = NamespacePrefix.infer(@definitions)
11
11
  take
12
12
  end
13
13
 
14
- attr_reader :type_count, :declaring_package, :root_namespace
14
+ attr_reader :type_count, :namespace_prefix
15
+
16
+ def declaring_package = @registry.declaring_package
15
17
 
16
18
  def packages = (@type_count.keys | @definitions.packages)
17
19
 
18
- def resolve(segments)
19
- outer, inner = segments.first(2)
20
- @declaring_package[outer == root_namespace ? inner : outer]
21
- end
20
+ def resolve(segments) = @registry.package_for(after_prefix(segments))
22
21
 
23
22
  private
24
23
 
25
24
  def take
26
25
  @definitions.each do |node, full, package|
27
- register(full, package)
26
+ @registry.register(after_prefix(full), package)
28
27
  @type_count[package] += 1 unless Syntax.direct_definitions(node).empty?
29
28
  end
30
29
  end
31
30
 
32
- def register(full, package)
33
- outer, inner = full.first(2)
34
- name = outer == root_namespace ? inner : outer
35
- @declaring_package[name] = package if name
31
+ def after_prefix(segments)
32
+ return [] if @namespace_prefix.first(segments.length) == segments
33
+
34
+ depth = @namespace_prefix.length.downto(0).find { segments.first(it) == @namespace_prefix.last(it) }
35
+ segments.drop(depth)
36
36
  end
37
37
  end
38
38
  end
@@ -0,0 +1,40 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Analysis
5
+ class ConstantRegistry
6
+ AMBIGUOUS = Object.new.freeze
7
+
8
+ def initialize
9
+ @declaring_package = {}
10
+ @shorthand = {}
11
+ end
12
+
13
+ attr_reader :declaring_package
14
+
15
+ def register(path, package)
16
+ return if path.empty?
17
+
18
+ claim(@declaring_package, path, package)
19
+ (1...path.length).each { claim(@shorthand, path.drop(it), package) }
20
+ end
21
+
22
+ def package_for(path)
23
+ found = path.length.downto(1).filter_map { claimed(path.first(it)) }.first
24
+ found unless found == AMBIGUOUS
25
+ end
26
+
27
+ private
28
+
29
+ def claim(claims, path, package)
30
+ key = path.join("::")
31
+ claims[key] = claims.fetch(key, package) == package ? package : AMBIGUOUS
32
+ end
33
+
34
+ def claimed(path)
35
+ key = path.join("::")
36
+ @declaring_package[key] || @shorthand[key]
37
+ end
38
+ end
39
+ end
40
+ end
@@ -0,0 +1,32 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Analysis
5
+ module NamespacePrefix
6
+ module_function
7
+
8
+ def infer(definitions)
9
+ build(deepest_definition_per_package(definitions))
10
+ end
11
+
12
+ def build(fulls, prefix = [])
13
+ depth = prefix.length
14
+ wrapper = shared_wrapper(fulls, depth)
15
+ return prefix unless wrapper
16
+
17
+ build(fulls.select { it[depth] == wrapper }, prefix + [wrapper])
18
+ end
19
+
20
+ def deepest_definition_per_package(definitions)
21
+ definitions.map { |_node, full, package| [package, full] }
22
+ .sort_by { |_package, full| full.length }
23
+ .to_h.values
24
+ end
25
+
26
+ def shared_wrapper(fulls, depth)
27
+ wrapper, count = fulls.filter_map { it[depth] if it[depth + 1] }.tally.max_by { |_wrapper, tally| tally }
28
+ wrapper if wrapper && count > fulls.size / 2
29
+ end
30
+ end
31
+ end
32
+ end
@@ -12,6 +12,8 @@ module Hashira
12
12
  sites.reject { |site| site.equal?(chosen) }
13
13
  end
14
14
 
15
+ def exact_sites = sites.select { it.types == canonical.types }
16
+
15
17
  def mass = canonical.mass
16
18
 
17
19
  def size = sites.size
@@ -24,7 +24,13 @@ module Hashira
24
24
 
25
25
  def chain(group) = group.each_cons(2) { |left, right| @sets.union(left, right) }
26
26
 
27
- def sized = built.select { |cluster| cluster.mass >= floor(cluster) }
27
+ def sized = built.filter_map { admitted(it) }
28
+
29
+ def admitted(cluster) = [cluster, exact_core(cluster)].compact.find { fits?(it) }
30
+
31
+ def fits?(cluster) = cluster.mass >= floor(cluster)
32
+
33
+ def exact_core(cluster) = Grouping.new(cluster.exact_sites).cluster
28
34
 
29
35
  def built = @sets.clusters.filter_map { |group| Grouping.new(group).cluster }
30
36
 
@@ -12,21 +12,23 @@ module Hashira
12
12
  @statements = statements
13
13
  end
14
14
 
15
- def fragments
16
- return [] if listing?
17
-
18
- lengths.flat_map { |length| slide(length) }
19
- end
15
+ def fragments = segments.flat_map { windows(it) }
20
16
 
21
17
  private
22
18
 
23
- def listing? = @statements.size >= LIST_RUN && shapes.uniq.size == 1
19
+ def segments = runs.chunk { listing?(it) }.filter_map { |listed, group| group.flatten(1) unless listed }
20
+
21
+ def listing?(run) = run.size >= LIST_RUN
22
+
23
+ def runs = shaped.slice_when { |left, right| left.last != right.last }.map { it.map(&:first) }
24
+
25
+ def shaped = @statements.map { [it, fragment([it]).types] }
24
26
 
25
- def shapes = @statements.map { fragment([it]).types }
27
+ def windows(segment) = lengths(segment).flat_map { |length| slide(segment, length) }
26
28
 
27
- def lengths = MIN_STATEMENTS..[@statements.size, MAX_STATEMENTS].min
29
+ def lengths(segment) = MIN_STATEMENTS..[segment.size, MAX_STATEMENTS].min
28
30
 
29
- def slide(length) = (0..(@statements.size - length)).map { fragment(@statements[it, length]) }
31
+ def slide(segment, length) = (0..(segment.size - length)).map { fragment(segment[it, length]) }
30
32
 
31
33
  def fragment(roots) = Fragment.new(@file, roots)
32
34
  end
@@ -5,17 +5,30 @@ module Hashira
5
5
  ROOT_PACKAGE = "(root)"
6
6
 
7
7
  def self.detect(directories)
8
- directories.empty? ? new(default_directories) : new(directories)
8
+ targets = directories.empty? ? default_directories : directories
9
+ new(targets.map { descend(it.delete_suffix("/")) })
9
10
  end
10
11
 
11
12
  def self.default_directories
12
- subdirectories = Dir["lib/*/"].map { File.basename(it) }
13
- raise Error, "no lib/ directory here — pass the source directory explicitly" if subdirectories.empty?
13
+ raise Error, "no lib/ directory here — pass the source directory explicitly" if Dir["lib/*/"].empty?
14
14
 
15
- subdirectories.size == 1 ? ["lib/#{subdirectories.first}"] : ["lib"]
15
+ ["lib"]
16
16
  end
17
17
  private_class_method :default_directories
18
18
 
19
+ def self.descend(directory)
20
+ child = only_subdirectory(directory)
21
+ return directory unless child && Dir["#{child}/*/"].any? && (Dir["#{directory}/*.rb"] - ["#{child}.rb"]).empty?
22
+
23
+ descend(child)
24
+ end
25
+
26
+ def self.only_subdirectory(directory)
27
+ subdirectories = Dir["#{directory}/*/"]
28
+ subdirectories.size == 1 ? subdirectories.first.delete_suffix("/") : nil
29
+ end
30
+ private_class_method :descend, :only_subdirectory
31
+
19
32
  def initialize(directories)
20
33
  missing = directories.reject { Dir.exist?(it) }
21
34
  raise Error, "no such directory: #{missing.join(", ")}" unless missing.empty?
@@ -29,7 +42,9 @@ module Hashira
29
42
 
30
43
  def package_for(path)
31
44
  first, rest = relative(path).delete_suffix(".rb").split("/", 2)
32
- rest || folder?(path, first) ? first : ROOT_PACKAGE
45
+ return ROOT_PACKAGE unless rest || folder?(path, first)
46
+
47
+ contested.include?(first) ? "#{directory_of(path)}/#{first}" : first
33
48
  end
34
49
 
35
50
  def relative(path) = path.delete_prefix("#{directory_of(path)}/")
@@ -42,6 +57,11 @@ module Hashira
42
57
 
43
58
  def folder?(path, name) = Dir.exist?("#{directory_of(path)}/#{name}")
44
59
 
60
+ def contested
61
+ @contested ||= @directories.flat_map { |directory| Dir["#{directory}/*/"].map { File.basename(it) } }
62
+ .tally.filter_map { |name, count| name if count > 1 }
63
+ end
64
+
45
65
  def directory_of(path)
46
66
  @directories.find { path.start_with?("#{it}/") } ||
47
67
  raise(Error, "#{path} is outside the analyzed directories")
@@ -1,5 +1,5 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  module Hashira
4
- VERSION = "0.2.0"
4
+ VERSION = "0.3.0"
5
5
  end
data/lib/hashira.rb CHANGED
@@ -9,7 +9,8 @@ require_relative "hashira/analysis/node_walk"
9
9
  require_relative "hashira/analysis/type_walk"
10
10
  require_relative "hashira/analysis/references"
11
11
  require_relative "hashira/analysis/definitions"
12
- require_relative "hashira/analysis/root_namespace"
12
+ require_relative "hashira/analysis/namespace_prefix"
13
+ require_relative "hashira/analysis/constant_registry"
13
14
  require_relative "hashira/analysis/census"
14
15
  require_relative "hashira/analysis/edge"
15
16
  require_relative "hashira/analysis/edge_map"
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.2.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Giacomo GK
@@ -27,6 +27,7 @@ files:
27
27
  - exe/hashira
28
28
  - lib/hashira.rb
29
29
  - lib/hashira/analysis/census.rb
30
+ - lib/hashira/analysis/constant_registry.rb
30
31
  - lib/hashira/analysis/cycle_findings.rb
31
32
  - lib/hashira/analysis/cycle_search.rb
32
33
  - lib/hashira/analysis/definitions.rb
@@ -35,9 +36,9 @@ files:
35
36
  - lib/hashira/analysis/finding.rb
36
37
  - lib/hashira/analysis/graph.rb
37
38
  - lib/hashira/analysis/metric.rb
39
+ - lib/hashira/analysis/namespace_prefix.rb
38
40
  - lib/hashira/analysis/node_walk.rb
39
41
  - lib/hashira/analysis/references.rb
40
- - lib/hashira/analysis/root_namespace.rb
41
42
  - lib/hashira/analysis/rule.rb
42
43
  - lib/hashira/analysis/sdp_check.rb
43
44
  - lib/hashira/analysis/sdp_violation_findings.rb
@@ -1,14 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- module Hashira
4
- module Analysis
5
- module RootNamespace
6
- module_function
7
-
8
- def infer(definitions)
9
- definitions.map { |_node, full, _package| full.first }
10
- .tally.max_by { |_name, count| count }&.first
11
- end
12
- end
13
- end
14
- end