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 +4 -4
- data/CHANGELOG.md +44 -0
- data/README.md +11 -4
- data/lib/hashira/analysis/census.rb +12 -12
- data/lib/hashira/analysis/constant_registry.rb +40 -0
- data/lib/hashira/analysis/namespace_prefix.rb +32 -0
- data/lib/hashira/duplication/cluster.rb +2 -0
- data/lib/hashira/duplication/clusterer.rb +7 -1
- data/lib/hashira/duplication/sequence.rb +11 -9
- data/lib/hashira/project.rb +25 -5
- data/lib/hashira/version.rb +1 -1
- data/lib/hashira.rb +2 -1
- metadata +3 -2
- data/lib/hashira/analysis/root_namespace.rb +0 -14
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: b806d4d5704515b3371235d38cdd4e60a0c37b98194c91922d57a8f5c448ffab
|
|
4
|
+
data.tar.gz: 774766f13fa3b820557cb613e9d9b0c7ffcca2d220c30611562d1467a21f142b
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
|
268
|
-
|
|
269
|
-
|
|
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
|
-
@
|
|
10
|
-
@
|
|
9
|
+
@registry = ConstantRegistry.new
|
|
10
|
+
@namespace_prefix = NamespacePrefix.infer(@definitions)
|
|
11
11
|
take
|
|
12
12
|
end
|
|
13
13
|
|
|
14
|
-
attr_reader :type_count, :
|
|
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
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
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
|
|
@@ -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.
|
|
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
|
|
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
|
|
27
|
+
def windows(segment) = lengths(segment).flat_map { |length| slide(segment, length) }
|
|
26
28
|
|
|
27
|
-
def lengths = MIN_STATEMENTS..[
|
|
29
|
+
def lengths(segment) = MIN_STATEMENTS..[segment.size, MAX_STATEMENTS].min
|
|
28
30
|
|
|
29
|
-
def slide(length) = (0..(
|
|
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
|
data/lib/hashira/project.rb
CHANGED
|
@@ -5,17 +5,30 @@ module Hashira
|
|
|
5
5
|
ROOT_PACKAGE = "(root)"
|
|
6
6
|
|
|
7
7
|
def self.detect(directories)
|
|
8
|
-
directories.empty? ?
|
|
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
|
-
|
|
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
|
-
|
|
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)
|
|
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")
|
data/lib/hashira/version.rb
CHANGED
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/
|
|
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.
|
|
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
|