visualize_packs 0.3.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: d1394594cbe0bf263a019593de9fd39ce2c191fb4c56d6be47fa0a52b2d20e6c
4
+ data.tar.gz: 7e81457c38334432d358d5e2b87e12330b1bc5ec90221a6d96626a71e9bf386f
5
+ SHA512:
6
+ metadata.gz: f5f8114ea83f715bb3d8539788f69b433818c22428aee06d2c2e43e721059d64b3a01006eb5d0b1dcf7bed358178fbb7fa6f931a406cf566c6a04a5a7c41fd14
7
+ data.tar.gz: 98896ac828d1b2b3e6062b8f0acda397bfa7f1c219c6fadb5d7bbec5eb3697599c615334470207abe9579fe5463aaa11e1ac272a234fd1a7bc674b4c619c0c9d
data/README.md ADDED
@@ -0,0 +1,35 @@
1
+ # visualize_packs
2
+
3
+ This gem helps visualize relationships between packs.
4
+
5
+ ![Example of visualization](docs/example.png)
6
+
7
+ # CLI Usage
8
+ ## bin/packs
9
+ For simpler use, add `bin/packs` via `use_packs` (https://github.com/rubyatscale/use_packs)
10
+ ```
11
+ bin/packs visualize # all packs
12
+ bin/packs visualize packs/a packs/b # subset of packs
13
+ bin/packs # enter interactive mode to select what packs to visualize
14
+ ```
15
+
16
+ # Ruby API Usage
17
+ ## Building a package graph for a selection of packages
18
+ ```ruby
19
+ # Select the packs you want to include
20
+ selected_packs = Packs.all
21
+ selected_packs = Packs.all.select{ |p| ['packs/my_pack_1', 'packs/my_pack_2'].include?(p.name) }
22
+ selected_packs = Packs.all.select{ |p| ['Team 1', 'Team 2'].include?(CodeOwnership.for_package(p)&.name) }
23
+ VisualizePacks.package_graph!(selected_packs)
24
+ ```
25
+
26
+ ## Building a team graph for specific teams
27
+ ```ruby
28
+ # Select the teams you want to include
29
+ selected_teams = CodeTeams.all
30
+ selected_teams = CodeTeams.all.select{ |t| ['Team 1', 'Team 2'].include?(t.name) }
31
+ VisualizePacks.team_graph!(selected_teams)
32
+ ```
33
+
34
+ # Want to change something or add a feature?
35
+ Submit a PR or post an issue!
@@ -0,0 +1,17 @@
1
+ # typed: strict
2
+
3
+ module VisualizePacks
4
+ # This stores graphviz-independent views of our package graph.
5
+ # It should be optimized for fast lookup (leveraging internal indexes, which are stable due to the immutability of the package nodes)
6
+ # A `TeamGraph` should be able to consume this and basically just create a reduced version
7
+ # Lastly, each one should implement a common interface, and graphviz should use that interface and take in either types of graph via the interface
8
+ module GraphInterface
9
+ extend T::Sig
10
+ extend T::Helpers
11
+ interface!
12
+
13
+ sig { abstract.returns(T::Set[NodeInterface]) }
14
+ def nodes
15
+ end
16
+ end
17
+ end
Binary file
@@ -0,0 +1,29 @@
1
+ # typed: strict
2
+
3
+ module VisualizePacks
4
+ module NodeInterface
5
+ extend T::Sig
6
+ extend T::Helpers
7
+ interface!
8
+
9
+ sig { abstract.returns(String) }
10
+ def name
11
+ end
12
+
13
+ sig { abstract.returns(String) }
14
+ def group_name
15
+ end
16
+
17
+ sig { abstract.returns(T::Hash[String, Integer]) }
18
+ def violations_by_node_name
19
+ end
20
+
21
+ sig { abstract.returns(T::Array[String]) }
22
+ def dependencies
23
+ end
24
+
25
+ sig { abstract.params(node_name: String).returns(T::Boolean) }
26
+ def depends_on?(node_name)
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,54 @@
1
+ # typed: strict
2
+
3
+ module VisualizePacks
4
+ class PackageGraph
5
+ extend T::Sig
6
+ include GraphInterface
7
+
8
+ sig { returns(T::Set[PackageNode]) }
9
+ attr_reader :package_nodes
10
+
11
+ sig { override.returns(T::Set[NodeInterface]) }
12
+ def nodes
13
+ package_nodes
14
+ end
15
+
16
+ sig { params(package_nodes: T::Set[PackageNode]).void }
17
+ def initialize(package_nodes:)
18
+ @package_nodes = package_nodes
19
+ @index_by_name = T.let({}, T::Hash[String, T.nilable(PackageNode)])
20
+ end
21
+
22
+ sig { returns(PackageGraph) }
23
+ def self.construct
24
+ package_nodes = Set.new
25
+ Packs.all.each do |p|
26
+ owner = CodeOwnership.for_package(p)
27
+
28
+ # Here we need to load the package violations and dependencies,
29
+ # so we need to use ParsePackwerk to parse that information.
30
+ package_info = ParsePackwerk.find(p.name)
31
+ next unless package_info # This should not happen unless packs/parse_packwerk change implementation
32
+
33
+ violations = package_info.violations
34
+ violations_by_package = violations.group_by(&:to_package_name).transform_values(&:count)
35
+
36
+ dependencies = package_info.dependencies
37
+
38
+ package_nodes << PackageNode.new(
39
+ name: p.name,
40
+ team_name: owner&.name || 'Unknown',
41
+ violations_by_package: violations_by_package,
42
+ dependencies: Set.new(dependencies)
43
+ )
44
+ end
45
+
46
+ PackageGraph.new(package_nodes: package_nodes)
47
+ end
48
+
49
+ sig { params(name: String).returns(T.nilable(PackageNode)) }
50
+ def package_by_name(name)
51
+ @index_by_name[name] ||= package_nodes.find { |node| node.name == name }
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,28 @@
1
+ # typed: strict
2
+
3
+ module VisualizePacks
4
+ class PackageNode < T::Struct
5
+ extend T::Sig
6
+ include NodeInterface
7
+
8
+ const :name, String
9
+ const :team_name, String
10
+ const :violations_by_package, T::Hash[String, Integer]
11
+ const :dependencies, T::Set[String]
12
+
13
+ sig { override.returns(T::Hash[String, Integer]) }
14
+ def violations_by_node_name
15
+ violations_by_package
16
+ end
17
+
18
+ sig { override.returns(String) }
19
+ def group_name
20
+ team_name
21
+ end
22
+
23
+ sig { override.params(node_name: String).returns(T::Boolean) }
24
+ def depends_on?(node_name)
25
+ dependencies.include?(node_name) || (violations_by_package[node_name] || 0) > 0
26
+ end
27
+ end
28
+ end
@@ -0,0 +1,170 @@
1
+ # typed: strict
2
+
3
+ module VisualizePacks
4
+ class PackageRelationships
5
+ extend T::Sig
6
+
7
+ OUTPUT_FILENAME = T.let('packwerk.png'.freeze, String)
8
+
9
+ sig { params(teams: T::Array[CodeTeams::Team]).void }
10
+ def create_package_graph_for_teams!(teams)
11
+ packages = Packs.all.select do |package|
12
+ teams.map(&:name).include?(CodeOwnership.for_package(package)&.name)
13
+ end
14
+
15
+ create_package_graph!(packages)
16
+ end
17
+
18
+ sig { params(teams: T::Array[CodeTeams::Team], show_all_teams: T::Boolean).void }
19
+ def create_team_graph!(teams, show_all_teams: false)
20
+ package_graph = PackageGraph.construct
21
+ team_graph = TeamGraph.from_package_graph(package_graph)
22
+ node_names = teams.map(&:name)
23
+
24
+ draw_graph!(team_graph, node_names, show_all_nodes: show_all_teams)
25
+ end
26
+
27
+ sig { params(packages: T::Array[Packs::Pack]).void }
28
+ def create_package_graph!(packages)
29
+ graph = PackageGraph.construct
30
+ node_names = packages.map(&:name)
31
+ draw_graph!(graph, node_names)
32
+ end
33
+
34
+ sig { params(packages: T::Array[Packs::Pack], show_all_nodes: T::Boolean).void }
35
+ def create_graph!(packages, show_all_nodes: false)
36
+ graph = PackageGraph.construct
37
+ node_names = packages.map(&:name)
38
+ draw_graph!(graph, node_names, show_all_nodes: show_all_nodes)
39
+ end
40
+
41
+ sig { params(graph: GraphInterface, node_names: T::Array[String], show_all_nodes: T::Boolean).void }
42
+ def draw_graph!(graph, node_names, show_all_nodes: false)
43
+ # SFDP looks better than dot in some cases, but less good in other cases.
44
+ # If your visualization looks bad, change the layout to other_layout!
45
+ # https://graphviz.org/docs/layouts/
46
+ default_layout = :dot
47
+ # other_layout = :sfdp
48
+ graphviz_graph = GraphViz.new(
49
+ :G,
50
+ type: :digraph,
51
+ dpi: 100,
52
+ layout: default_layout,
53
+ label: "Visualization of #{node_names.count} packs, generated using `bin/packs`",
54
+ fontsize: 24,
55
+ labelloc: "t",
56
+ )
57
+
58
+ # Create graph nodes
59
+ graphviz_nodes = T.let({}, T::Hash[String, GraphViz::Node])
60
+
61
+ nodes_to_draw = graph.nodes.select{|n| node_names.include?(n.name) }
62
+
63
+ nodes_to_draw.each do |node|
64
+ graphviz_nodes[node.name] = add_node(node, graphviz_graph)
65
+ end
66
+
67
+ # Draw all edges
68
+ nodes_to_draw.each do |node|
69
+ node.dependencies.each do |to_node|
70
+ next unless node_names.include?(to_node)
71
+
72
+ add_dependency(
73
+ graph: graphviz_graph,
74
+ node1: T.must(graphviz_nodes[node.name]),
75
+ node2: T.must(graphviz_nodes[to_node]),
76
+ )
77
+ end
78
+
79
+ node.violations_by_node_name.each do |to_node_name, violation_count|
80
+ next unless node_names.include?(to_node_name)
81
+
82
+ add_violation(
83
+ graph: graphviz_graph,
84
+ node1: T.must(graphviz_nodes[node.name]),
85
+ node2: T.must(graphviz_nodes[to_node_name]),
86
+ violation_count: violation_count
87
+ )
88
+ end
89
+ end
90
+
91
+ add_legend(graphviz_graph)
92
+
93
+ # Save graph to filesystem
94
+ puts "Outputting to: #{OUTPUT_FILENAME}"
95
+ graphviz_graph.output(png: OUTPUT_FILENAME)
96
+ puts 'Finished!'
97
+ end
98
+
99
+ sig { params(node: NodeInterface, graph: GraphViz).returns(GraphViz::Node) }
100
+ def add_node(node, graph)
101
+ node_options = {
102
+ fontsize: 26.0,
103
+ fontcolor: 'black',
104
+ fillcolor: 'white',
105
+ color: 'black',
106
+ height: 1.0,
107
+ style: 'filled, rounded',
108
+ shape: 'box',
109
+ }
110
+
111
+ graph.add_nodes(node.name, **node_options)
112
+ end
113
+
114
+ sig { params(graph: GraphViz).void }
115
+ def add_legend(graph)
116
+ legend = graph.add_graph("legend")
117
+
118
+ # This commented out code was used to generate an image that I edited by hand.
119
+ # I was unable to figure out how to:
120
+ # - put a box around the legend
121
+ # - layout the node pairs in vertical order
122
+ # - give it a title
123
+ # So I just generated this using graphviz and then pulled the image in.
124
+ # a_node = legend.add_nodes("packs/a")
125
+ # b_node = legend.add_nodes("packs/b")
126
+ # c_node = legend.add_nodes("packs/c")
127
+ # d_node = legend.add_nodes("packs/d")
128
+ # e_node = legend.add_nodes("packs/e")
129
+ # f_node = legend.add_nodes("packs/f")
130
+
131
+ # add_dependency(graph: legend, node1: a_node, node2: b_node, label: 'Dependency in package.yml')
132
+ # add_violation(graph: legend, node1: c_node, node2: d_node, violation_count: 1, label: 'Violations (few)')
133
+ # add_violation(graph: legend, node1: e_node, node2: f_node, violation_count: 30, label: 'Violations (many)')
134
+
135
+ image = legend.add_node("",
136
+ shape: "image",
137
+ image: Pathname.new(__dir__).join("./legend.png").to_s,
138
+ )
139
+ end
140
+
141
+ sig { params(graph: GraphViz, node1: GraphViz::Node, node2: GraphViz::Node, violation_count: Integer, label: T.nilable(String)).void }
142
+ def add_violation(graph:, node1:, node2:, violation_count:, label: nil)
143
+ max_edge_width = 10
144
+
145
+ edge_width = [
146
+ [(violation_count / 5).to_i, 1].max, # rubocop:disable Lint/NumberConversion
147
+ max_edge_width,
148
+ ].min
149
+
150
+ opts = { color: 'red', style: 'dashed', penwidth: edge_width }
151
+ if label
152
+ opts.merge!(label: label)
153
+ end
154
+
155
+ graph.add_edges(node1, node2, opts)
156
+ end
157
+
158
+ sig { params(graph: GraphViz, node1: GraphViz::Node, node2: GraphViz::Node, label: T.nilable(String)).void }
159
+ def add_dependency(graph:, node1:, node2:, label: nil)
160
+ opts = { color: 'darkgreen' }
161
+ if label
162
+ opts.merge!(label: label)
163
+ end
164
+
165
+ graph.add_edges(node1, node2, opts)
166
+ end
167
+ end
168
+
169
+ private_constant :PackageRelationships
170
+ end
@@ -0,0 +1,56 @@
1
+ # typed: strict
2
+
3
+ module VisualizePacks
4
+ #
5
+ # A team graph reduces a PackageGraph by aggregating over packages owned by teams
6
+ #
7
+ class TeamGraph
8
+ extend T::Sig
9
+ include GraphInterface
10
+
11
+ sig { override.returns(T::Set[NodeInterface]) }
12
+ def nodes
13
+ @team_nodes
14
+ end
15
+
16
+ sig { params(team_nodes: T::Set[TeamNode]).void }
17
+ def initialize(team_nodes:)
18
+ @team_nodes = team_nodes
19
+ end
20
+
21
+ sig { params(package_graph: PackageGraph).returns(TeamGraph) }
22
+ def self.from_package_graph(package_graph)
23
+ team_nodes = T.let(Set.new, T::Set[TeamNode])
24
+ package_graph.package_nodes.group_by(&:team_name).each do |team, package_nodes_for_team|
25
+ violations_by_team = {}
26
+ package_nodes_for_team.map(&:violations_by_package).each do |new_violations_by_package|
27
+ new_violations_by_package.each do |pack_name, count|
28
+ # We first get the pack owner of the violated package
29
+ other_package = package_graph.package_by_name(pack_name)
30
+ next if other_package.nil?
31
+ other_team = other_package.team_name
32
+ violations_by_team[other_team] ||= 0
33
+ # Then we add the violations on that team together
34
+ # TODO: We may want to ignore this if team == other_team to avoid arrows pointing to self, but maybe not!
35
+ violations_by_team[other_team] += count
36
+ end
37
+ end
38
+
39
+ dependencies = Set.new
40
+ package_nodes_for_team.map(&:dependencies).reduce(Set.new, :+).each do |dependency|
41
+ other_pack = package_graph.package_by_name(dependency)
42
+ next if other_pack.nil?
43
+ dependencies << other_pack.team_name
44
+ end
45
+
46
+ team_nodes << TeamNode.new(
47
+ name: team,
48
+ violations_by_team: violations_by_team,
49
+ dependencies: dependencies
50
+ )
51
+ end
52
+
53
+ TeamGraph.new(team_nodes: team_nodes)
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,27 @@
1
+ # typed: strict
2
+
3
+ module VisualizePacks
4
+ class TeamNode < T::Struct
5
+ extend T::Sig
6
+ include NodeInterface
7
+
8
+ const :name, String
9
+ const :violations_by_team, T::Hash[String, Integer]
10
+ const :dependencies, T::Set[String]
11
+
12
+ sig { override.returns(T::Hash[String, Integer]) }
13
+ def violations_by_node_name
14
+ violations_by_team
15
+ end
16
+
17
+ sig { override.returns(String) }
18
+ def group_name
19
+ name
20
+ end
21
+
22
+ sig { override.params(node_name: String).returns(T::Boolean) }
23
+ def depends_on?(node_name)
24
+ dependencies.include?(node_name) || (violations_by_node_name[node_name] || 0) > 0
25
+ end
26
+ end
27
+ end
@@ -0,0 +1,29 @@
1
+ # typed: strict
2
+
3
+ require 'packs-specification'
4
+ require 'parse_packwerk'
5
+ require 'code_ownership'
6
+ require 'graphviz'
7
+ require 'sorbet-runtime'
8
+
9
+ require 'visualize_packs/node_interface'
10
+ require 'visualize_packs/graph_interface'
11
+ require 'visualize_packs/team_node'
12
+ require 'visualize_packs/package_node'
13
+ require 'visualize_packs/team_graph'
14
+ require 'visualize_packs/package_graph'
15
+ require 'visualize_packs/package_relationships'
16
+
17
+ module VisualizePacks
18
+ extend T::Sig
19
+
20
+ sig { params(packages: T::Array[Packs::Pack]).void }
21
+ def self.package_graph!(packages)
22
+ PackageRelationships.new.create_package_graph!(packages)
23
+ end
24
+
25
+ sig { params(teams: T::Array[CodeTeams::Team]).void }
26
+ def self.team_graph!(teams)
27
+ PackageRelationships.new.create_team_graph!(teams)
28
+ end
29
+ end
metadata ADDED
@@ -0,0 +1,197 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: visualize_packs
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.3.0
5
+ platform: ruby
6
+ authors:
7
+ - Gusto Engineers
8
+ autorequire:
9
+ bindir: bin
10
+ cert_chain: []
11
+ date: 2023-08-09 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: sorbet-runtime
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - ">="
18
+ - !ruby/object:Gem::Version
19
+ version: '0'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - ">="
25
+ - !ruby/object:Gem::Version
26
+ version: '0'
27
+ - !ruby/object:Gem::Dependency
28
+ name: packs
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - ">="
32
+ - !ruby/object:Gem::Version
33
+ version: '0'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - ">="
39
+ - !ruby/object:Gem::Version
40
+ version: '0'
41
+ - !ruby/object:Gem::Dependency
42
+ name: parse_packwerk
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - ">="
46
+ - !ruby/object:Gem::Version
47
+ version: '0'
48
+ type: :runtime
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - ">="
53
+ - !ruby/object:Gem::Version
54
+ version: '0'
55
+ - !ruby/object:Gem::Dependency
56
+ name: code_ownership
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - ">="
60
+ - !ruby/object:Gem::Version
61
+ version: '0'
62
+ type: :runtime
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - ">="
67
+ - !ruby/object:Gem::Version
68
+ version: '0'
69
+ - !ruby/object:Gem::Dependency
70
+ name: rake
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - ">="
74
+ - !ruby/object:Gem::Version
75
+ version: '0'
76
+ type: :runtime
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - ">="
81
+ - !ruby/object:Gem::Version
82
+ version: '0'
83
+ - !ruby/object:Gem::Dependency
84
+ name: ruby-graphviz
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - ">="
88
+ - !ruby/object:Gem::Version
89
+ version: '0'
90
+ type: :runtime
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - ">="
95
+ - !ruby/object:Gem::Version
96
+ version: '0'
97
+ - !ruby/object:Gem::Dependency
98
+ name: bundler
99
+ requirement: !ruby/object:Gem::Requirement
100
+ requirements:
101
+ - - "~>"
102
+ - !ruby/object:Gem::Version
103
+ version: 2.2.16
104
+ type: :development
105
+ prerelease: false
106
+ version_requirements: !ruby/object:Gem::Requirement
107
+ requirements:
108
+ - - "~>"
109
+ - !ruby/object:Gem::Version
110
+ version: 2.2.16
111
+ - !ruby/object:Gem::Dependency
112
+ name: rspec
113
+ requirement: !ruby/object:Gem::Requirement
114
+ requirements:
115
+ - - "~>"
116
+ - !ruby/object:Gem::Version
117
+ version: '3.0'
118
+ type: :development
119
+ prerelease: false
120
+ version_requirements: !ruby/object:Gem::Requirement
121
+ requirements:
122
+ - - "~>"
123
+ - !ruby/object:Gem::Version
124
+ version: '3.0'
125
+ - !ruby/object:Gem::Dependency
126
+ name: sorbet
127
+ requirement: !ruby/object:Gem::Requirement
128
+ requirements:
129
+ - - ">="
130
+ - !ruby/object:Gem::Version
131
+ version: '0'
132
+ type: :development
133
+ prerelease: false
134
+ version_requirements: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '0'
139
+ - !ruby/object:Gem::Dependency
140
+ name: tapioca
141
+ requirement: !ruby/object:Gem::Requirement
142
+ requirements:
143
+ - - ">="
144
+ - !ruby/object:Gem::Version
145
+ version: '0'
146
+ type: :development
147
+ prerelease: false
148
+ version_requirements: !ruby/object:Gem::Requirement
149
+ requirements:
150
+ - - ">="
151
+ - !ruby/object:Gem::Version
152
+ version: '0'
153
+ description: A gem to visualize connections in a Ruby app that uses packs
154
+ email:
155
+ - dev@gusto.com
156
+ executables: []
157
+ extensions: []
158
+ extra_rdoc_files: []
159
+ files:
160
+ - README.md
161
+ - lib/visualize_packs.rb
162
+ - lib/visualize_packs/graph_interface.rb
163
+ - lib/visualize_packs/legend.png
164
+ - lib/visualize_packs/node_interface.rb
165
+ - lib/visualize_packs/package_graph.rb
166
+ - lib/visualize_packs/package_node.rb
167
+ - lib/visualize_packs/package_relationships.rb
168
+ - lib/visualize_packs/team_graph.rb
169
+ - lib/visualize_packs/team_node.rb
170
+ homepage: https://github.com/rubyatscale/visualize_packs
171
+ licenses:
172
+ - MIT
173
+ metadata:
174
+ homepage_uri: https://github.com/rubyatscale/visualize_packs
175
+ source_code_uri: https://github.com/rubyatscale/visualize_packs
176
+ changelog_uri: https://github.com/rubyatscale/visualize_packs/releases
177
+ allowed_push_host: https://rubygems.org
178
+ post_install_message:
179
+ rdoc_options: []
180
+ require_paths:
181
+ - lib
182
+ required_ruby_version: !ruby/object:Gem::Requirement
183
+ requirements:
184
+ - - ">="
185
+ - !ruby/object:Gem::Version
186
+ version: '2.6'
187
+ required_rubygems_version: !ruby/object:Gem::Requirement
188
+ requirements:
189
+ - - ">="
190
+ - !ruby/object:Gem::Version
191
+ version: '0'
192
+ requirements: []
193
+ rubygems_version: 3.1.6
194
+ signing_key:
195
+ specification_version: 4
196
+ summary: A gem to visualize connections in a Ruby app that uses packs
197
+ test_files: []