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.
Files changed (46) hide show
  1. checksums.yaml +7 -0
  2. data/CHANGELOG.md +21 -0
  3. data/LICENSE.txt +21 -0
  4. data/README.md +98 -0
  5. data/exe/hashira +9 -0
  6. data/lib/hashira/analysis/census.rb +41 -0
  7. data/lib/hashira/analysis/cycle_findings.rb +33 -0
  8. data/lib/hashira/analysis/cycle_search.rb +44 -0
  9. data/lib/hashira/analysis/definitions.rb +27 -0
  10. data/lib/hashira/analysis/edge.rb +9 -0
  11. data/lib/hashira/analysis/edge_map.rb +36 -0
  12. data/lib/hashira/analysis/finding.rb +11 -0
  13. data/lib/hashira/analysis/graph.rb +56 -0
  14. data/lib/hashira/analysis/metric.rb +14 -0
  15. data/lib/hashira/analysis/node_walk.rb +14 -0
  16. data/lib/hashira/analysis/references.rb +39 -0
  17. data/lib/hashira/analysis/root_namespace.rb +14 -0
  18. data/lib/hashira/analysis/rule.rb +22 -0
  19. data/lib/hashira/analysis/sdp_check.rb +18 -0
  20. data/lib/hashira/analysis/sdp_violation_findings.rb +27 -0
  21. data/lib/hashira/analysis/syntax.rb +27 -0
  22. data/lib/hashira/analysis/type_walk.rb +26 -0
  23. data/lib/hashira/ci/accepted.rb +44 -0
  24. data/lib/hashira/ci/edge_diff_report.rb +47 -0
  25. data/lib/hashira/ci/gate.rb +29 -0
  26. data/lib/hashira/ci/ratchet.rb +44 -0
  27. data/lib/hashira/cli/command_line.rb +86 -0
  28. data/lib/hashira/cli/fail_on.rb +26 -0
  29. data/lib/hashira/cli/options.rb +9 -0
  30. data/lib/hashira/cli/run.rb +38 -0
  31. data/lib/hashira/cli/usage.rb +40 -0
  32. data/lib/hashira/cli.rb +26 -0
  33. data/lib/hashira/diagram/dot.rb +16 -0
  34. data/lib/hashira/diagram/mermaid.rb +23 -0
  35. data/lib/hashira/diagram/renderer.rb +20 -0
  36. data/lib/hashira/error.rb +5 -0
  37. data/lib/hashira/pipeline.rb +28 -0
  38. data/lib/hashira/project.rb +47 -0
  39. data/lib/hashira/report/dependency_map.rb +27 -0
  40. data/lib/hashira/report/finding_lines.rb +26 -0
  41. data/lib/hashira/report/json.rb +41 -0
  42. data/lib/hashira/report/metrics_table.rb +38 -0
  43. data/lib/hashira/report/text.rb +58 -0
  44. data/lib/hashira/version.rb +5 -0
  45. data/lib/hashira.rb +41 -0
  46. 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,9 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ lib = File.expand_path("../lib", __dir__)
5
+ $LOAD_PATH.unshift(lib) unless $LOAD_PATH.include?(lib)
6
+
7
+ require "hashira"
8
+
9
+ exit Hashira::CLI.run(ARGV)
@@ -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,9 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Analysis
5
+ Edge = Data.define(:from, :to) do
6
+ def to_s = "#{from} -> #{to}"
7
+ end
8
+ end
9
+ 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,11 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Analysis
5
+ Finding = Data.define(:kind, :package, :message, :evidence, :cycle) do
6
+ def initialize(cycle: nil, **rest) = super
7
+
8
+ def to_h = super.compact
9
+ end
10
+ end
11
+ 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,14 @@
1
+ # frozen_string_literal: true
2
+
3
+ module Hashira
4
+ module Analysis
5
+ module NodeWalk
6
+ module_function
7
+
8
+ def each_node(node, &)
9
+ yield(node)
10
+ node.compact_child_nodes.each { each_node(_1, &) }
11
+ end
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