hashira 0.1.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 +7 -0
- data/CHANGELOG.md +21 -0
- data/LICENSE.txt +21 -0
- data/README.md +98 -0
- data/exe/hashira +9 -0
- data/lib/hashira/analysis/census.rb +41 -0
- data/lib/hashira/analysis/cycle_findings.rb +33 -0
- data/lib/hashira/analysis/cycle_search.rb +44 -0
- data/lib/hashira/analysis/definitions.rb +27 -0
- data/lib/hashira/analysis/edge.rb +9 -0
- data/lib/hashira/analysis/edge_map.rb +36 -0
- data/lib/hashira/analysis/finding.rb +11 -0
- data/lib/hashira/analysis/graph.rb +56 -0
- data/lib/hashira/analysis/metric.rb +14 -0
- data/lib/hashira/analysis/node_walk.rb +14 -0
- data/lib/hashira/analysis/references.rb +39 -0
- data/lib/hashira/analysis/root_namespace.rb +14 -0
- data/lib/hashira/analysis/rule.rb +22 -0
- data/lib/hashira/analysis/sdp_check.rb +18 -0
- data/lib/hashira/analysis/sdp_violation_findings.rb +27 -0
- data/lib/hashira/analysis/syntax.rb +27 -0
- data/lib/hashira/analysis/type_walk.rb +26 -0
- data/lib/hashira/ci/accepted.rb +44 -0
- data/lib/hashira/ci/edge_diff_report.rb +47 -0
- data/lib/hashira/ci/gate.rb +29 -0
- data/lib/hashira/ci/ratchet.rb +44 -0
- data/lib/hashira/cli/command_line.rb +86 -0
- data/lib/hashira/cli/fail_on.rb +26 -0
- data/lib/hashira/cli/options.rb +9 -0
- data/lib/hashira/cli/run.rb +38 -0
- data/lib/hashira/cli/usage.rb +40 -0
- data/lib/hashira/cli.rb +26 -0
- data/lib/hashira/diagram/dot.rb +16 -0
- data/lib/hashira/diagram/mermaid.rb +23 -0
- data/lib/hashira/diagram/renderer.rb +20 -0
- data/lib/hashira/error.rb +5 -0
- data/lib/hashira/pipeline.rb +28 -0
- data/lib/hashira/project.rb +47 -0
- data/lib/hashira/report/dependency_map.rb +27 -0
- data/lib/hashira/report/finding_lines.rb +26 -0
- data/lib/hashira/report/json.rb +41 -0
- data/lib/hashira/report/metrics_table.rb +38 -0
- data/lib/hashira/report/text.rb +58 -0
- data/lib/hashira/version.rb +5 -0
- data/lib/hashira.rb +41 -0
- metadata +91 -0
checksums.yaml
ADDED
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
---
|
|
2
|
+
SHA256:
|
|
3
|
+
metadata.gz: 9641685509b4a0fa17ee87a9e29f56906f7ea487622185f935b3a544c9f214bf
|
|
4
|
+
data.tar.gz: a2e3e7da103a76e5dd080402c15eb8a3b83ebae1b1d5047303e0a33971fcc203
|
|
5
|
+
SHA512:
|
|
6
|
+
metadata.gz: bef9ad9240fdfc1631211b46f87a7d1da6cbb826f4b5b0fabcd8957432915229d85f1d68463fb2c0fef67d8a61d561acadbcba8a14db87b3c8d8b55036d36f7b
|
|
7
|
+
data.tar.gz: e8ac4c4da5da2a53415bb16e64599387ccf815e679e767c8b16fcd8200ce2f4f3bcae5c48439d9d480388b657e7c02b16d4c1e258b9a62037f471a40518da278
|
data/CHANGELOG.md
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
All notable changes to this project will be documented in this file.
|
|
4
|
+
|
|
5
|
+
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
|
|
6
|
+
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
|
+
|
|
8
|
+
## [0.1.0] - 2026-07-19
|
|
9
|
+
|
|
10
|
+
### Added
|
|
11
|
+
|
|
12
|
+
- Package (layer) metrics from the AST via Prism: TC, Ca, Ce, instability, cycles.
|
|
13
|
+
- Plain-English findings with file-level evidence, each stating something
|
|
14
|
+
provable from the AST: cycles and SDP violations.
|
|
15
|
+
- CI gate (`--fail-on cycles,sdp`) and edge-set ratchet (`--ratchet`,
|
|
16
|
+
`--update-baseline`), with accepted-by-design findings recorded in the
|
|
17
|
+
baseline.
|
|
18
|
+
- Output formats: text, JSON, Graphviz dot, Mermaid (`--format`, `--json`).
|
|
19
|
+
- `--help` and `--version`.
|
|
20
|
+
|
|
21
|
+
[0.1.0]: https://github.com/giacope/hashira/releases/tag/v0.1.0
|
data/LICENSE.txt
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Giacomo GK
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
data/README.md
ADDED
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
# hashira
|
|
2
|
+
|
|
3
|
+
:classical_building: Package coupling metrics for Ruby, read from the AST via Prism.
|
|
4
|
+
|
|
5
|
+
## Installation
|
|
6
|
+
|
|
7
|
+
```ruby
|
|
8
|
+
gem "hashira"
|
|
9
|
+
```
|
|
10
|
+
|
|
11
|
+
Requires Ruby 3.4+.
|
|
12
|
+
|
|
13
|
+
## Getting Started
|
|
14
|
+
|
|
15
|
+
```sh
|
|
16
|
+
hashira # auto-detects lib/<gem>
|
|
17
|
+
hashira lib/myapp # or point it at a directory
|
|
18
|
+
hashira app lib # or several — one shared graph
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
You get a report like:
|
|
22
|
+
|
|
23
|
+
```
|
|
24
|
+
package TC Ca Ce I Cyc
|
|
25
|
+
----------------------------------------
|
|
26
|
+
analysis 20 1 0 0.00 -
|
|
27
|
+
report 7 2 0 0.00 -
|
|
28
|
+
ci 4 1 2 0.67 -
|
|
29
|
+
cli 6 0 4 1.00 -
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
## How to Read the Numbers
|
|
33
|
+
|
|
34
|
+
Every folder under the target directory is a **package**. For each one:
|
|
35
|
+
|
|
36
|
+
- **TC** — how many classes/modules it defines.
|
|
37
|
+
- **Ca** — how many packages depend *on* it (afferent, incoming).
|
|
38
|
+
- **Ce** — how many packages it depends *upon* (efferent, outgoing).
|
|
39
|
+
- **I** — instability, `Ce / (Ce + Ca)`, from 0 to 1.
|
|
40
|
+
|
|
41
|
+
**I = 0**: everyone depends on it, it depends on no one — a foundation, expensive to change. **I = 1**: nobody depends on it — free to change. Neither is good or bad on its own; a CLI layer *should* be at 1.00, a core domain layer near 0.00. The findings are about arrows pointing the wrong way:
|
|
42
|
+
|
|
43
|
+
- **SDP violation** — a stable package depends on a less stable one.
|
|
44
|
+
- **Cycle** — packages depending on each other in a loop.
|
|
45
|
+
|
|
46
|
+
Each finding comes with file-level evidence; for cycles, the shortest cycle path and its lightest edge. What a finding means for your design is your call.
|
|
47
|
+
|
|
48
|
+
## How It Works
|
|
49
|
+
|
|
50
|
+
A dependency edge A→B exists when a file in package A references a constant declared by package B. Declarations are read from the AST; strings and comments are invisible. A type counts toward TC only if it defines a method directly in its body — pure namespace wrappers don't count. The root namespace is inferred (the most common outermost constant), so `App::Alpha` and `Alpha` resolve to the same package.
|
|
51
|
+
|
|
52
|
+
Each edge carries a **weight**: the number of constant references backing it. A root-level file `x.rb` folds into package `x` when a sibling folder `x/` exists; everything else at the top level lands in `(root)`.
|
|
53
|
+
|
|
54
|
+
## CI
|
|
55
|
+
|
|
56
|
+
Fail the build when specific finding kinds exist:
|
|
57
|
+
|
|
58
|
+
```sh
|
|
59
|
+
hashira --fail-on cycles,sdp # kinds: cycles, sdp
|
|
60
|
+
```
|
|
61
|
+
|
|
62
|
+
Or ratchet: commit a baseline and only allow the edge set to shrink. New edges fail with the evidence that introduced them.
|
|
63
|
+
|
|
64
|
+
```sh
|
|
65
|
+
hashira --ratchet # compares against hashira_baseline.json
|
|
66
|
+
hashira --update-baseline # lock in improvements
|
|
67
|
+
hashira --ratchet --baseline PATH
|
|
68
|
+
```
|
|
69
|
+
|
|
70
|
+
Findings accepted by design can be recorded in the baseline with a reason — they leave reports and gates, keeping a one-line reminder each:
|
|
71
|
+
|
|
72
|
+
```json
|
|
73
|
+
"accepted": [
|
|
74
|
+
{"kind": "sdp_violation", "package": "models", "reason": "config is generated, churn is harmless"}
|
|
75
|
+
]
|
|
76
|
+
```
|
|
77
|
+
|
|
78
|
+
## Other Formats
|
|
79
|
+
|
|
80
|
+
```sh
|
|
81
|
+
hashira --json # machine format with full evidence + edge weights
|
|
82
|
+
hashira --format dot # Graphviz digraph
|
|
83
|
+
hashira --format mermaid # Mermaid diagram
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
## Why No A, D, or Zones
|
|
87
|
+
|
|
88
|
+
Classic package-metrics tools also measure abstractness (A), distance-from-main-sequence (D), and the Pain/Uselessness zones. Those assume formal interfaces are how you decouple. Idiomatic Ruby decouples via duck typing, so any abstractness proxy pins to ~0 and the "zone" verdict just restates I. Deliberately skipped.
|
|
89
|
+
|
|
90
|
+
## Contributing
|
|
91
|
+
|
|
92
|
+
Bug reports and pull requests are welcome at
|
|
93
|
+
[github.com/giacope/hashira](https://github.com/giacope/hashira). See
|
|
94
|
+
[CONTRIBUTING.md](CONTRIBUTING.md) and the [code of conduct](CODE_OF_CONDUCT.md).
|
|
95
|
+
|
|
96
|
+
## License
|
|
97
|
+
|
|
98
|
+
[MIT](LICENSE.txt)
|
data/exe/hashira
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Analysis
|
|
5
|
+
class Census
|
|
6
|
+
def initialize(project, trees)
|
|
7
|
+
@definitions = Definitions.new(project, trees)
|
|
8
|
+
@type_count = Hash.new(0)
|
|
9
|
+
@declaring_package = {}
|
|
10
|
+
@root_namespace = RootNamespace.infer(@definitions)
|
|
11
|
+
take
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
attr_reader :type_count, :declaring_package, :root_namespace
|
|
15
|
+
|
|
16
|
+
def packages = (@type_count.keys | @definitions.packages)
|
|
17
|
+
|
|
18
|
+
def resolve(segments)
|
|
19
|
+
outer, inner = segments.first(2)
|
|
20
|
+
@declaring_package[outer == root_namespace ? inner : outer]
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def take
|
|
26
|
+
@definitions.each do |node, full, package|
|
|
27
|
+
register(full, package)
|
|
28
|
+
@type_count[package] += 1 unless Syntax.direct_definitions(node).empty?
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# Assumes each top-level name is declared by one package; if two
|
|
33
|
+
# packages declare the same name, the last file (in sort order) wins.
|
|
34
|
+
def register(full, package)
|
|
35
|
+
outer, inner = full.first(2)
|
|
36
|
+
name = outer == root_namespace ? inner : outer
|
|
37
|
+
@declaring_package[name] = package if name
|
|
38
|
+
end
|
|
39
|
+
end
|
|
40
|
+
end
|
|
41
|
+
end
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Analysis
|
|
5
|
+
class CycleFindings < Rule
|
|
6
|
+
KIND = "cycle"
|
|
7
|
+
|
|
8
|
+
def list
|
|
9
|
+
graph.packages.select { graph.cyclic?(_1) }.sort.map { cycle_finding(_1) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
private
|
|
13
|
+
|
|
14
|
+
def cycle_finding(package)
|
|
15
|
+
path = graph.cycle_path(package)
|
|
16
|
+
finding(package:, cycle: path, evidence: evidence(path),
|
|
17
|
+
message: message(package, path, graph.weakest_edge(path)))
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def message(package, path, weak_edge)
|
|
21
|
+
weak_from, weak_to = weak_edge
|
|
22
|
+
weight = graph.weight(weak_from, weak_to)
|
|
23
|
+
"#{package} can reach itself: #{path.join(" -> ")} — any change may ripple back " \
|
|
24
|
+
"around. The lightest edge on this cycle is #{weak_from} -> #{weak_to} " \
|
|
25
|
+
"(#{weight} ref#{"s" unless weight == 1})."
|
|
26
|
+
end
|
|
27
|
+
|
|
28
|
+
def evidence(path)
|
|
29
|
+
path.each_cons(2).flat_map { |from, to| graph.evidence_for(from, to).to_a.first(2) }
|
|
30
|
+
end
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Analysis
|
|
5
|
+
class CycleSearch
|
|
6
|
+
def initialize(dependencies, package)
|
|
7
|
+
@dependencies = dependencies
|
|
8
|
+
@package = package
|
|
9
|
+
@predecessor = {}
|
|
10
|
+
@queue = dependencies[package].to_a.each { @predecessor[_1] = package }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def path
|
|
14
|
+
trace_back([@package]) if cycle?
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
private
|
|
18
|
+
|
|
19
|
+
def cycle?
|
|
20
|
+
while (node = @queue.shift)
|
|
21
|
+
return true if node == @package
|
|
22
|
+
|
|
23
|
+
visit(node)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
|
|
27
|
+
def visit(node)
|
|
28
|
+
@dependencies[node].each do |neighbor|
|
|
29
|
+
next if @predecessor.key?(neighbor)
|
|
30
|
+
|
|
31
|
+
@predecessor[neighbor] = node
|
|
32
|
+
@queue << neighbor
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
def trace_back(path)
|
|
37
|
+
first = path.first
|
|
38
|
+
return path if first == @package && path.size > 1
|
|
39
|
+
|
|
40
|
+
trace_back(path.unshift(@predecessor[first]))
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Analysis
|
|
5
|
+
class Definitions
|
|
6
|
+
include Enumerable
|
|
7
|
+
|
|
8
|
+
def initialize(project, trees)
|
|
9
|
+
@project = project
|
|
10
|
+
@trees = trees
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
def each(&)
|
|
14
|
+
@trees.each { |file, tree| definitions_in(file, tree, &) }
|
|
15
|
+
end
|
|
16
|
+
|
|
17
|
+
def packages = @trees.keys.map { @project.package_for(_1) }.uniq
|
|
18
|
+
|
|
19
|
+
private
|
|
20
|
+
|
|
21
|
+
def definitions_in(file, tree)
|
|
22
|
+
package = @project.package_for(file)
|
|
23
|
+
TypeWalk.each_definition(tree) { |node, full| yield node, full, package }
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Analysis
|
|
5
|
+
class EdgeMap
|
|
6
|
+
def initialize(project, census)
|
|
7
|
+
@project = project
|
|
8
|
+
@census = census
|
|
9
|
+
@dependencies = empty_sets
|
|
10
|
+
@evidence = empty_sets
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
attr_reader :dependencies, :evidence
|
|
14
|
+
|
|
15
|
+
def record(file, tree)
|
|
16
|
+
from = @project.package_for(file)
|
|
17
|
+
source = @project.relative(file)
|
|
18
|
+
References.each_sighting(tree).each do |segments, line|
|
|
19
|
+
record_reference(from, source, segments, line)
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
private
|
|
24
|
+
|
|
25
|
+
def empty_sets = Hash.new { |hash, key| hash[key] = Set.new }
|
|
26
|
+
|
|
27
|
+
def record_reference(from, source, segments, line)
|
|
28
|
+
to = @census.resolve(segments)
|
|
29
|
+
return unless to && to != from
|
|
30
|
+
|
|
31
|
+
@dependencies[from] << to
|
|
32
|
+
@evidence[[from, to]] << "#{source}:#{line}: #{segments.join("::")}"
|
|
33
|
+
end
|
|
34
|
+
end
|
|
35
|
+
end
|
|
36
|
+
end
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Analysis
|
|
5
|
+
class Graph
|
|
6
|
+
def initialize(project, trees, census)
|
|
7
|
+
@census = census
|
|
8
|
+
@edge_map = EdgeMap.new(project, census)
|
|
9
|
+
trees.each { |file, tree| @edge_map.record(file, tree) }
|
|
10
|
+
end
|
|
11
|
+
|
|
12
|
+
def packages = @census.packages
|
|
13
|
+
|
|
14
|
+
def dependencies_of(package) = dependencies[package].to_a.sort
|
|
15
|
+
|
|
16
|
+
def dependents_of(package) = packages.select { dependencies[_1].include?(package) }.sort
|
|
17
|
+
|
|
18
|
+
def edge_list
|
|
19
|
+
dependencies.sort.flat_map { |from, tos| tos.sort.map { Edge.new(from:, to: _1) } }
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def weighted_edges
|
|
23
|
+
edge_list.map do |edge|
|
|
24
|
+
from, to = edge.deconstruct
|
|
25
|
+
[from, to, weight(from, to)]
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
|
|
29
|
+
def evidence_for(from, to) = @edge_map.evidence[[from, to]]
|
|
30
|
+
|
|
31
|
+
def metric_for(package)
|
|
32
|
+
Metric.new(type_count: @census.type_count[package],
|
|
33
|
+
afferent: dependents_of(package).size,
|
|
34
|
+
efferent: dependencies[package].size)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
def metrics = packages.to_h { [_1, metric_for(_1)] }
|
|
38
|
+
|
|
39
|
+
def sdp_violations = SdpCheck.new(dependencies, metrics).violations
|
|
40
|
+
|
|
41
|
+
def cyclic?(package) = !!cycle_path(package)
|
|
42
|
+
|
|
43
|
+
def weight(from, to) = evidence_for(from, to).size
|
|
44
|
+
|
|
45
|
+
def cycle_path(package) = CycleSearch.new(dependencies, package).path
|
|
46
|
+
|
|
47
|
+
def weakest_edge(path)
|
|
48
|
+
path.each_cons(2).min_by { |from, to| weight(from, to) }
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
private
|
|
52
|
+
|
|
53
|
+
def dependencies = @edge_map.dependencies
|
|
54
|
+
end
|
|
55
|
+
end
|
|
56
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Analysis
|
|
5
|
+
Metric = Data.define(:type_count, :afferent, :efferent) do
|
|
6
|
+
def instability
|
|
7
|
+
total = efferent + afferent
|
|
8
|
+
total.zero? ? 0.0 : efferent.to_f / total
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def to_h = { tc: type_count, ca: afferent, ce: efferent, i: instability }
|
|
12
|
+
end
|
|
13
|
+
end
|
|
14
|
+
end
|
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module Analysis
|
|
7
|
+
module References
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def list(tree)
|
|
11
|
+
each_sighting(tree).map(&:first)
|
|
12
|
+
end
|
|
13
|
+
|
|
14
|
+
def each_sighting(tree)
|
|
15
|
+
[].tap { collect(tree, _1) }
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def collect(node, accumulator)
|
|
19
|
+
return unless node
|
|
20
|
+
return accumulator << [Syntax.path_segments(node), node.location.start_line] if constant?(node)
|
|
21
|
+
|
|
22
|
+
branches(node).each { collect(_1, accumulator) }
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
def constant?(node) = node.is_a?(Prism::ConstantPathNode) || node.is_a?(Prism::ConstantReadNode)
|
|
26
|
+
|
|
27
|
+
def branches(node)
|
|
28
|
+
definition?(node) ? definition_branches(node) : node.compact_child_nodes
|
|
29
|
+
end
|
|
30
|
+
|
|
31
|
+
def definition?(node) = node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
32
|
+
|
|
33
|
+
def definition_branches(node)
|
|
34
|
+
superclass = node.is_a?(Prism::ClassNode) ? node.superclass : nil
|
|
35
|
+
[superclass, node.body]
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
39
|
+
end
|
|
@@ -0,0 +1,14 @@
|
|
|
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
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Analysis
|
|
5
|
+
class Rule
|
|
6
|
+
def initialize(project, graph)
|
|
7
|
+
@project = project
|
|
8
|
+
@graph = graph
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
private
|
|
12
|
+
|
|
13
|
+
attr_reader :project, :graph
|
|
14
|
+
|
|
15
|
+
def metrics = @metrics ||= graph.metrics
|
|
16
|
+
|
|
17
|
+
def finding(**attributes)
|
|
18
|
+
Finding.new(kind: self.class::KIND, cycle: nil, **attributes)
|
|
19
|
+
end
|
|
20
|
+
end
|
|
21
|
+
end
|
|
22
|
+
end
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Analysis
|
|
5
|
+
class SdpCheck
|
|
6
|
+
def initialize(dependencies, metrics)
|
|
7
|
+
@dependencies = dependencies
|
|
8
|
+
@metrics = metrics
|
|
9
|
+
end
|
|
10
|
+
|
|
11
|
+
def violations
|
|
12
|
+
@dependencies.flat_map do |from, tos|
|
|
13
|
+
tos.select { @metrics[_1].instability > @metrics[from].instability }.map { [from, _1] }
|
|
14
|
+
end
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
end
|
|
18
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
module Hashira
|
|
4
|
+
module Analysis
|
|
5
|
+
class SdpViolationFindings < Rule
|
|
6
|
+
KIND = "sdp_violation"
|
|
7
|
+
|
|
8
|
+
def list
|
|
9
|
+
graph.sdp_violations.sort_by { |from, to| instability(from) - instability(to) }
|
|
10
|
+
.map { |from, to| violation_finding(from, to) }
|
|
11
|
+
end
|
|
12
|
+
|
|
13
|
+
private
|
|
14
|
+
|
|
15
|
+
def violation_finding(from, to)
|
|
16
|
+
finding(package: from, evidence: graph.evidence_for(from, to).to_a.first(5),
|
|
17
|
+
message: "#{from} (I=#{label(from)}) depends on the LESS stable #{to} " \
|
|
18
|
+
"(I=#{label(to)}) — churn in #{to} will force churn in #{from}. " \
|
|
19
|
+
"Invert the edge or extract the stable part of #{to} that #{from} needs.")
|
|
20
|
+
end
|
|
21
|
+
|
|
22
|
+
def instability(package) = metrics[package].instability
|
|
23
|
+
|
|
24
|
+
def label(package) = format("%.2f", instability(package))
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module Analysis
|
|
7
|
+
module Syntax
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def path_segments(node)
|
|
11
|
+
case node
|
|
12
|
+
when Prism::ConstantReadNode then [node.name.to_s]
|
|
13
|
+
when Prism::ConstantPathNode then path_segments(node.parent) + name_of(node)
|
|
14
|
+
else []
|
|
15
|
+
end
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def name_of(node) = [node.name.to_s]
|
|
19
|
+
|
|
20
|
+
def direct_definitions(type_node)
|
|
21
|
+
body = type_node.body
|
|
22
|
+
statements = body.is_a?(Prism::StatementsNode) ? body.body : [body]
|
|
23
|
+
statements.grep(Prism::DefNode)
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
27
|
+
end
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "prism"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module Analysis
|
|
7
|
+
module TypeWalk
|
|
8
|
+
module_function
|
|
9
|
+
|
|
10
|
+
def each_definition(node, prefix = [], &)
|
|
11
|
+
return each_child_definition(node, prefix, &) unless type_node?(node)
|
|
12
|
+
|
|
13
|
+
full = prefix + Syntax.path_segments(node.constant_path)
|
|
14
|
+
yield(node, full)
|
|
15
|
+
each_child_definition(node.body, full, &)
|
|
16
|
+
end
|
|
17
|
+
|
|
18
|
+
def type_node?(node) = node.is_a?(Prism::ClassNode) || node.is_a?(Prism::ModuleNode)
|
|
19
|
+
|
|
20
|
+
def each_child_definition(node, prefix, &)
|
|
21
|
+
children = node ? node.compact_child_nodes : []
|
|
22
|
+
children.each { each_definition(_1, prefix, &) }
|
|
23
|
+
end
|
|
24
|
+
end
|
|
25
|
+
end
|
|
26
|
+
end
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
# frozen_string_literal: true
|
|
2
|
+
|
|
3
|
+
require "json"
|
|
4
|
+
|
|
5
|
+
module Hashira
|
|
6
|
+
module CI
|
|
7
|
+
class Accepted
|
|
8
|
+
Screened = Data.define(:all, :accepted)
|
|
9
|
+
|
|
10
|
+
Entry = Data.define(:kind, :package, :reason) do
|
|
11
|
+
def self.from(hash) = new(kind: hash["kind"], package: hash["package"], reason: hash["reason"])
|
|
12
|
+
|
|
13
|
+
def matches?(finding) = [kind, package] == [finding.kind, finding.package]
|
|
14
|
+
|
|
15
|
+
def label = reason || "accepted (no reason recorded)"
|
|
16
|
+
|
|
17
|
+
def to_h = { kind:, package:, reason: }.compact
|
|
18
|
+
end
|
|
19
|
+
|
|
20
|
+
def self.load(path)
|
|
21
|
+
return new([]) unless path && File.exist?(path)
|
|
22
|
+
|
|
23
|
+
new(JSON.parse(File.read(path)).fetch("accepted", []))
|
|
24
|
+
end
|
|
25
|
+
|
|
26
|
+
def initialize(entries)
|
|
27
|
+
@entries = entries.map { Entry.from(_1) }
|
|
28
|
+
end
|
|
29
|
+
|
|
30
|
+
def entries = @entries.map(&:to_h)
|
|
31
|
+
|
|
32
|
+
def screen(findings)
|
|
33
|
+
accepted, live = findings.map { [_1, reason_for(_1)] }.partition(&:last)
|
|
34
|
+
Screened.new(all: live.map(&:first), accepted:)
|
|
35
|
+
end
|
|
36
|
+
|
|
37
|
+
private
|
|
38
|
+
|
|
39
|
+
def reason_for(finding)
|
|
40
|
+
@entries.find { _1.matches?(finding) }&.label
|
|
41
|
+
end
|
|
42
|
+
end
|
|
43
|
+
end
|
|
44
|
+
end
|