visualize_packs 0.3.1 → 0.5.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 47c8efb8d5a5aa315cfea8c05a028fb0a042d55496f126a9f4b9bb031010deed
4
- data.tar.gz: 7008a8249cad36c4d592513be2feac9ce77b316d30a3110dc4d83b366649721b
3
+ metadata.gz: 6f359f93291f20b1d7d77b62a32c52a7fa76baf86444201239f2b8e2bd629b83
4
+ data.tar.gz: e52a5b63f4ef80429727ed92d6e624329c6fe98904e0406b7b39b6f20a996121
5
5
  SHA512:
6
- metadata.gz: 78c9e025d8d45167c33d803773d38865f99baf0fe5234c48c38962fb6af6e0cdb1c02961ded5a6c03e8fb9c64dc783ebc9789cd60c3199d32d1aca8fda79a395
7
- data.tar.gz: 1e668887bae444783dd611d690923cfde71866620446b505e47b5765a19a2f787b73192ed41f1c38ed9faf24f0fabf82d42f9d836bc35a2a2ab0f0857d6e1756
6
+ metadata.gz: 2ec569b998d850151acf257f1cff5baf31a6fa7f3453dc1505776d33027b71f26fcb369c2d1b05cfef6d8d468b47a5b826e78859d485169b3bdcc163a634335c
7
+ data.tar.gz: 692b0eba45954340cbc043efd5b3937009ff402549ae3557e9d0013e827962c289ca1b9dc00eab399d9b0627099db42276912e566dfbd5e978960442f9bef649
data/README.md CHANGED
@@ -1,35 +1,47 @@
1
1
  # visualize_packs
2
+ Visualize_packs helps you visualize the structure, intended and actual, of your package-based Ruby application.
2
3
 
3
- This gem helps visualize relationships between packs.
4
+ This gem takes a minimal approach in that it only outputs a graph in the format of [graphviz](https://graphviz.org/)' [dot language](https://graphviz.org/doc/info/lang.html). Install graphviz and use one of the chains of commands from below to generate full or partial images of the graphs of your application.
4
5
 
5
- ![Example of visualization](docs/example.png)
6
+ ## Visualize your entire application
7
+ ```
8
+ bundle exec visualize_packs > packs.dot && dot packs.dot -Tpng -o packs.png && open packs.png
9
+ ```
10
+
11
+ ## packs.png for every package in your app
12
+
13
+ This will generate a local dependency diagram for every pack in your app
6
14
 
7
- # CLI Usage
8
- ## bin/packs
9
- For simpler use, add `bin/packs` via `use_packs` (https://github.com/rubyatscale/use_packs)
10
15
  ```
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
16
+ find . -iname 'package.yml' | sed 's/\/package.yml//g' | sed 's/\.\///' | xargs -I % sh -c "bundle exec visualize_packs --only=% > %/packs.dot && dot %/packs.dot -Tpng -o %/packs.png"
14
17
  ```
15
18
 
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)
19
+ If your app is large and has many packages and violations, the above graphs will likely be too big. Try this version to get only the edges to and from the focus package for each diagram:
20
+
24
21
  ```
22
+ find . -iname 'package.yml' | sed 's/\/package.yml//g' | sed 's/\.\///' | xargs -I % sh -c "bundle exec visualize_packs --only=% --only-edges-to-focus > %/packs.dot && dot %/packs.dot -Tpng -o %/packs.png"
23
+ ```
24
+
25
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)
26
+ ## Get help
27
+
28
+ ```
29
+ bundle exec visualize_packs --help
32
30
  ```
33
31
 
34
- # Want to change something or add a feature?
35
- Submit a PR or post an issue!
32
+ ## What outputs look like
33
+
34
+ ![Sample diagrams produced](https://github.com/shageman/visualize_packs/blob/main/diagram_examples.png?raw=true)
35
+
36
+ ## Contributing
37
+
38
+ To contribute, install graphviz (and the `dot` command). You must also have Ruby 3.2.2 and bundler installed. Then
39
+
40
+ ```
41
+ cd spec
42
+ ./test.sh
43
+ ```
44
+
45
+ Then, in `spec/sample_app` visually compare all `X.png` to `X_new.png` to make sure you are happy with the changes. If you, are, run `./update_cassettes.sh` in the same folder and commit the new test files with your changes.
46
+
47
+ If you have imagemagick installed, you can then also run `./create_comparison.sh` to create one big image of all the before (left) and after (right) versions of the sample diagrams.
@@ -0,0 +1,39 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "pathname"
5
+ require "optparse"
6
+ require "ostruct"
7
+
8
+ require_relative '../lib/visualize_packs'
9
+ require_relative '../lib/options'
10
+
11
+ options = Options.new
12
+
13
+ OptionParser.new do |opt|
14
+ opt.on('--no-layers', "Don't show architectural layers") { |o| options.show_layers = false }
15
+ opt.on('--no-dependencies', "Don't show accepted dependencies") { |o| options.show_dependencies = false }
16
+ opt.on('--no-todos', "Don't show package todos") { |o| options.show_todos = false }
17
+ opt.on('--no-privacy', "Don't show privacy enforcement") { |o| options.show_privacy = false }
18
+ opt.on('--no-teams', "Don't show team colors") { |o| options.show_teams = false }
19
+
20
+ opt.on('--focus-on=PACKAGE', "Don't show privacy enforcement") { |o| options.focus_package = o }
21
+ opt.on('--only-edges-to-focus', "If focus is set, this shows only the edges to/from the focus node instead of all edges in the focussed graph. This only has effect when --focus-on is set.") { |o| options.show_only_edges_to_focus_package = true }
22
+
23
+ opt.on('--roll_nested_todos_into_top_level', "Don't show nested packages (not counting root). Connect edges to top-level package instead") { |o| options.roll_nested_todos_into_top_level = true }
24
+ opt.on('--focus_folder=FOLDER', "Draw package diagram only for packages in FOLDER") { |o| options.focus_folder = o }
25
+ opt.on('--no_nested_relationships', "Don't draw nested package relationships") { |o| options.show_nested_relationships = false }
26
+
27
+ opt.on('--remote-base-url=PACKAGE', "Link package nodes to an URL (affects graphviz SVG generation)") { |o| options.remote_base_url = o }
28
+
29
+ opt.on_tail("-h", "--help", "Show this message") do
30
+ puts opt
31
+ exit
32
+ end
33
+ end.parse!
34
+
35
+ puts VisualizePacks.package_graph!(
36
+ options,
37
+ ParsePackwerk::Configuration.fetch.raw,
38
+ Packs.all.map { |pack| ParsePackwerk.find(pack.name) }
39
+ )
data/lib/graph.dot.erb ADDED
@@ -0,0 +1,162 @@
1
+ digraph package_diagram {
2
+ rankdir=TD
3
+ graph [
4
+ labelloc="t"
5
+ fontname="Helvetica,Arial,sans-serif"
6
+ dpi=100
7
+ layout=dot
8
+ label=<%= title %>
9
+ fontsize=18
10
+ ]
11
+ node [
12
+ fontname="Helvetica,Arial,sans-serif"
13
+ fontsize=26.0
14
+ fontcolor=black
15
+ fillcolor=white
16
+ color=black
17
+ height=1.0
18
+ style=filled
19
+ shape=plain
20
+ ]
21
+ <%- architecture_layers.each_with_index do |layer_name, index| -%>
22
+ subgraph <%= layer_name -%> {
23
+ shape=box
24
+ color=darkgrey
25
+ fillcolor=lightblue
26
+ style=filled
27
+ <%- if layer_name != "NotInLayer" && options.show_layers -%>
28
+ label="<%= layer_name -%>"
29
+ cluster=true
30
+ rank = <%= index -%>
31
+ <%- else -%>
32
+ cluster=false
33
+ <%- end -%>
34
+ <%- grouped_packages[layer_name].each do |package| -%>
35
+ "<%= package.name -%>" [
36
+ fontsize=<%= options.focus_package && package.name == options.focus_package ? 18.0 : 12.0 -%>
37
+ <%- if options.remote_base_url %>
38
+ URL="<%= options.remote_base_url %>/<%= package.name == '.' ? '' : package.name -%>"
39
+ <%- end %>
40
+ <%- if options.show_teams && code_owner(package) -%>
41
+ style=filled
42
+ fillcolor="<%= node_color.call(code_owner(package)) %>"
43
+ <%- end -%>
44
+ label= <%- if package.enforce_privacy && options.show_privacy -%><
45
+ <table border='0' cellborder='1' cellspacing='0' cellpadding='16'><tr><td>
46
+ <table border='0' cellborder='1' cellspacing='0' cellpadding='4'>
47
+ <tr> <td port='private'> <%= package.name -%> </td> </tr>
48
+ </table>
49
+ </td></tr></table>
50
+ >
51
+ <%- else -%><
52
+ <table border='0' cellborder='1' cellspacing='0' cellpadding='4'>
53
+ <tr> <td align='left'> <%= package.name -%> </td> </tr>
54
+ </table>
55
+ >
56
+ <%- end -%>
57
+ ]
58
+
59
+ <%- end -%>
60
+ }
61
+ <%- grouped_packages[layer_name].each do |package| -%>
62
+ <%- if options.show_layers -%>
63
+ <%- if index > 0 -%>
64
+ <%- grouped_packages[raw_config['architecture_layers'][index - 1]].each do |upper_layer_node| -%>
65
+ <%- if all_package_names.include?(upper_layer_node.name) && all_package_names.include?(package.name) -%>
66
+ "<%= upper_layer_node.name -%>" -> "<%= package.name -%>" [ style=invis ]
67
+ <%- end -%>
68
+ <%- end -%>
69
+ <%- end -%>
70
+ <%- end -%>
71
+ <%- end -%>
72
+ <%- end -%>
73
+ <%- if options.show_dependencies -%>
74
+ <%- all_packages.each do |package| -%>
75
+ <%- package.dependencies.each do |to_package| -%>
76
+ <%- if show_edge.call(package.name, to_package) -%>
77
+ "<%= package.name -%>" -> "<%= to_package -%>" [ color=darkgreen ]
78
+ <%- end -%>
79
+ <%- end -%>
80
+ <%- end -%>
81
+ <%- end -%>
82
+ <%- if options.show_todos -%>
83
+ <%- all_packages.each do |package| -%>
84
+ <%- violations_by_package = package.violations.group_by(&:to_package_name) -%>
85
+ <%- violations_by_package.keys.each do |violations_to_package| -%>
86
+ <%- violation_types = violations_by_package[violations_to_package].group_by(&:type) -%>
87
+ <%- violation_types.keys.each do |violation_type| -%>
88
+ <%- if show_edge.call(package.name, violations_to_package) -%>
89
+ "<%= package.name -%>" -> "<%= violations_to_package -%>"<%= violation_type == 'privacy' ? ':private' : '' -%> [ color=darkred style=dashed
90
+ constraint=false
91
+ # headlabel="<%= violation_type -%>"
92
+ <%- if violation_type == 'privacy' -%>
93
+ arrowhead=crow
94
+ <%- elsif violation_type == 'architecture' -%>
95
+ arrowhead=invodot
96
+ <%- elsif violation_type == 'visibility' -%>
97
+ arrowhead=obox
98
+ <%- elsif violation_type == 'dependency' -%>
99
+ arrowhead=odot
100
+ <%- end -%>
101
+ <%-
102
+ max_edge_width = 10
103
+ edge_width = (violation_types[violation_type].count / max_violation_count.to_f * max_edge_width).to_i
104
+ -%>
105
+ penwidth=<%= edge_width -%>
106
+ ]
107
+ <%- end -%>
108
+ <%- end -%>
109
+ <%- end -%>
110
+ <%- end -%>
111
+ <%- end -%>
112
+ <%- if options.show_nested_relationships -%>
113
+ <%- all_packages.each do |package| -%>
114
+ <%- all_packages.each do |nested_package| -%>
115
+ <%- if nested_package.name.include?(package.name) && !(nested_package.name == package.name) -%>
116
+ "<%= package.name -%>" -> "<%= nested_package.name -%>" [ color=purple penwidth=3 ]
117
+ <%- end -%>
118
+ <%- end -%>
119
+ <%- end -%>
120
+ <%- end -%>
121
+ subgraph cluster_legend {
122
+ fontsize=16
123
+ label="Edges Styles and Arrow Heads"
124
+ A [ fontsize=12 shape=box label="package"]
125
+ B [ fontsize=12 shape=box label="package"]
126
+ C [ fontsize=12 shape=box label="package"]
127
+ D [ fontsize=12 shape=box label="package"]
128
+ E [ fontsize=12 shape=box label="package"]
129
+ F [ fontsize=12 shape=box label="package"]
130
+ G [ fontsize=12 shape=box label="package"]
131
+ H [ fontsize=12 shape=box label="package"]
132
+ I [ fontsize=12 shape=box label="package"]
133
+ J [ fontsize=12 shape=box label="package"]
134
+ K [ fontsize=12 shape=box label="package"]
135
+ L [ fontsize=12 shape=box label="package"]
136
+ A -> B [label="accepted dependency" color=darkgreen]
137
+ C -> D [label="privac todo" color=darkred style=dashed arrowhead=crow]
138
+ E -> F [label="architecture todo" color=darkred style=dashed arrowhead=invodot]
139
+ G -> H [label="visibility todo" color=darkred style=dashed arrowhead=obox]
140
+ I -> J [label="dependency todo" color=darkred style=dashed arrowhead=odot]
141
+ K -> L [label="nested package" color=purple penwidth=3]
142
+ }
143
+ <%- if options.show_teams && all_team_names != [] -%>
144
+ subgraph cluster_teams_legend {
145
+ fontsize=16
146
+ label="Team Colors"
147
+
148
+ <%- all_team_names.each do |team_name| -%>
149
+ <%- if team_name -%>
150
+ "<%= team_name %><%= team_name %>" [
151
+ label="<%= team_name %>"
152
+ style=filled
153
+ fillcolor="<%= node_color.call(team_name) %>"
154
+ fontsize=12
155
+ shape=box
156
+ ]
157
+ <%- end %>
158
+ <%- end -%>
159
+ }
160
+ J -> "<%= all_team_names.last %><%= all_team_names.last %>" [style=invis]
161
+ <%- end -%>
162
+ }
data/lib/options.rb ADDED
@@ -0,0 +1,33 @@
1
+ class Options
2
+ attr_accessor :show_layers
3
+ attr_accessor :show_dependencies
4
+ attr_accessor :show_todos
5
+ attr_accessor :show_privacy
6
+ attr_accessor :show_teams
7
+
8
+ attr_accessor :focus_package
9
+ attr_accessor :show_only_edges_to_focus_package
10
+
11
+ attr_accessor :roll_nested_todos_into_top_level
12
+ attr_accessor :focus_folder
13
+ attr_accessor :show_nested_relationships
14
+
15
+ attr_accessor :remote_base_url
16
+
17
+ def initialize
18
+ @show_layers = true
19
+ @show_dependencies = true
20
+ @show_todos = true
21
+ @show_privacy = true
22
+ @show_teams = true
23
+
24
+ @focus_package = nil
25
+ @show_only_edges_to_focus_package = false
26
+
27
+ @roll_nested_todos_into_top_level = false
28
+ @focus_folder = nil
29
+ @show_nested_relationships = true
30
+
31
+ @remote_base_url = nil
32
+ end
33
+ end
@@ -1,29 +1,213 @@
1
- # typed: strict
2
-
1
+ require 'erb'
3
2
  require 'packs-specification'
4
3
  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'
4
+ require 'digest/md5'
16
5
 
17
6
  module VisualizePacks
18
- extend T::Sig
19
7
 
20
- sig { params(packages: T::Array[Packs::Pack]).void }
21
- def self.package_graph!(packages)
22
- PackageRelationships.new.create_package_graph!(packages)
8
+ def self.package_graph!(options, raw_config, packages)
9
+ raise ArgumentError, "Package #{options.focus_package} does not exist. Found packages #{packages.map(&:name).join(", ")}" if options.focus_package && !packages.map(&:name).include?(options.focus_package)
10
+
11
+ all_packages = filtered(packages, options.focus_package, options.focus_folder).sort_by {|x| x.name }
12
+ all_package_names = all_packages.map &:name
13
+
14
+ all_packages = remove_nested_packs(all_packages) if options.roll_nested_todos_into_top_level
15
+
16
+ show_edge = show_edge_builder(options, all_package_names)
17
+ node_color = node_color_builder()
18
+ max_violation_count = max_violation_count(all_packages, show_edge)
19
+
20
+ title = diagram_title(options, max_violation_count)
21
+
22
+ architecture_layers = (raw_config['architecture_layers'] || []) + ["NotInLayer"]
23
+ grouped_packages = architecture_layers.inject({}) do |result, key|
24
+ result[key] = []
25
+ result
26
+ end
27
+
28
+ all_packages.each do |package|
29
+ key = package.config['layer'] || "NotInLayer"
30
+ if architecture_layers.include?(key)
31
+ grouped_packages[key] << package
32
+ else
33
+ raise RuntimeError, "Package #{package.name} has architecture layer key #{key}. Known layers are only #{architecture_layers.join(", ")}"
34
+ end
35
+ end
36
+
37
+
38
+ all_team_names = all_packages.map { |p| code_owner(p) }.uniq
39
+
40
+ file = File.open(File.expand_path File.dirname(__FILE__) + "/graph.dot.erb")
41
+ templ = file.read.gsub(/^ *(<%.+%>) *$/, '\1')
42
+ template = ERB.new(templ, trim_mode: "<>-")
43
+ template.result(binding)
44
+ end
45
+
46
+ private
47
+
48
+ def self.code_owner(package)
49
+ package.config.dig("metadata", "owner") || package.config["owner"]
50
+ end
51
+
52
+ def self.diagram_title(options, max_violation_count)
53
+ app_name = File.basename(Dir.pwd)
54
+ focus_edge_info = options.focus_package && options.show_only_edges_to_focus_package ? "showing only edges to/from focus pack" : "showing all edges between visible packs"
55
+ focus_info = options.focus_package || options.focus_folder ? "Focus on #{[options.focus_package, options.focus_folder].compact.join(' and ')} (#{focus_edge_info})" : "All packs"
56
+ skipped_info =
57
+ [
58
+ options.show_layers ? nil : "hiding layers",
59
+ options.show_dependencies ? nil : "hiding dependencies",
60
+ options.show_todos ? nil : "hiding todos",
61
+ options.show_privacy ? nil : "hiding privacy",
62
+ options.show_teams ? nil : "hiding teams",
63
+ options.roll_nested_todos_into_top_level ? "hiding nested packs" : nil,
64
+ options.show_nested_relationships ? nil : "hiding nested relationships",
65
+ ].compact.join(', ').strip
66
+ main_title = "#{app_name}: #{focus_info}#{skipped_info != '' ? ' - ' + skipped_info : ''}"
67
+ sub_title = ""
68
+ if options.show_todos && max_violation_count
69
+ sub_title = "<br/><font point-size='12'>Widest todo edge is #{max_violation_count} violation#{max_violation_count > 1 ? 's' : ''}</font>"
70
+ end
71
+ "<<b>#{main_title}</b>#{sub_title}>"
72
+ end
73
+
74
+ def self.show_edge_builder(options, all_package_names)
75
+ return lambda do |start_node, end_node|
76
+ (
77
+ !options.show_only_edges_to_focus_package &&
78
+ all_package_names.include?(start_node) &&
79
+ all_package_names.include?(end_node)
80
+ ) ||
81
+ (
82
+ options.show_only_edges_to_focus_package &&
83
+ all_package_names.include?(start_node) &&
84
+ all_package_names.include?(end_node) &&
85
+ [start_node, end_node].include?(options.focus_package)
86
+ )
87
+ end
88
+ end
89
+
90
+ def self.node_color_builder
91
+ return lambda do |text|
92
+ return unless text
93
+ hash_value = Digest::SHA256.hexdigest(text.encode('utf-8'))
94
+ color_code = hash_value[0, 6]
95
+ r = color_code[0, 2].to_i(16) % 128 + 128
96
+ g = color_code[2, 2].to_i(16) % 128 + 128
97
+ b = color_code[4, 2].to_i(16) % 128 + 128
98
+ hex = "#%02X%02X%02X" % [r, g, b]
99
+ end
23
100
  end
24
101
 
25
- sig { params(teams: T::Array[CodeTeams::Team]).void }
26
- def self.team_graph!(teams)
27
- PackageRelationships.new.create_team_graph!(teams)
102
+ def self.max_violation_count(all_packages, show_edge)
103
+ violation_counts = {}
104
+ all_packages.each do |package|
105
+ violations_by_package = package.violations.group_by(&:to_package_name)
106
+ violations_by_package.keys.each do |violations_to_package|
107
+ violation_types = violations_by_package[violations_to_package].group_by(&:type)
108
+ violation_types.keys.each do |violation_type|
109
+ if show_edge.call(package.name, violations_to_package)
110
+ key = "#{package.name}->#{violations_to_package}:#{violation_type}"
111
+ violation_counts[key] = violation_types[violation_type].count
112
+ # violation_counts[key] += 1
113
+ end
114
+ end
115
+ end
116
+ end
117
+ violation_counts.values.max
118
+ end
119
+
120
+ def self.filtered(packages, filter_package, filter_folder)
121
+ return packages unless filter_package || filter_folder
122
+
123
+ result = packages.map { |pack| pack.name }
124
+
125
+ if filter_package
126
+ result = [filter_package]
127
+ result += packages.select{ |p| p.dependencies.include? filter_package }.map { |pack| pack.name }
128
+ result += ParsePackwerk.find(filter_package).dependencies
129
+ result += packages.select{ |p| p.violations.map(&:to_package_name).include? filter_package }.map { |pack| pack.name }
130
+ result += ParsePackwerk.find(filter_package).violations.map(&:to_package_name)
131
+ result = result.uniq
132
+ end
133
+
134
+ if filter_folder
135
+ result = result.select { |p| p.include? filter_folder }
136
+ end
137
+
138
+ result.map { |pack_name| ParsePackwerk.find(pack_name) }
139
+ end
140
+
141
+ def self.all_nested_packages(all_package_names)
142
+ all_package_names.reject { |p| p == '.' }.inject({}) do |result, package|
143
+ package_map_tally = all_package_names.map { |other_package| Pathname.new(package).parent.to_s.include?(other_package) }
144
+ if package_map_tally.tally[true].nil?
145
+ #nothing to do
146
+ elsif package_map_tally.tally[true] > 1
147
+ raise "Can't handle multiple levels of nesting atm"
148
+ elsif package_map_tally.tally[true] == 1
149
+ result[package] = all_package_names[package_map_tally.find_index(true)]
150
+ end
151
+ result
152
+ end
153
+ end
154
+
155
+ def self.remove_nested_packs(packages)
156
+ nested_packages = all_nested_packages(packages.map { |p| p.name })
157
+
158
+ # top-level packages
159
+ morphed_packages = packages.map do |package|
160
+ if nested_packages.include?(package.name)
161
+ package
162
+ else
163
+ # nested packages
164
+ nested_packages.keys.each do |nested_package_name|
165
+ if nested_packages[nested_package_name] == package.name
166
+ nested_package = packages.find { |p| p.name == nested_package_name }
167
+
168
+ package = ParsePackwerk::Package.new(
169
+ name: package.name,
170
+ enforce_dependencies: package.enforce_dependencies,
171
+ enforce_privacy: package.enforce_privacy,
172
+ public_path: package.public_path,
173
+ metadata: package.metadata,
174
+ dependencies: package.dependencies + nested_package.dependencies,
175
+ config: package.config,
176
+ violations: package.violations + nested_package.violations
177
+ )
178
+ end
179
+ end
180
+
181
+
182
+ morphed_dependencies = package.dependencies.map do |d|
183
+ nested_packages[d] || d
184
+ end.uniq.reject { |p| p == package.name }
185
+
186
+ morphed_violations = package.violations.map do |v|
187
+ ParsePackwerk::Violation.new(
188
+ type: v.type,
189
+ to_package_name: nested_packages[v.to_package_name] || v.to_package_name,
190
+ class_name: v.class_name,
191
+ files: v.files
192
+ )
193
+ end.reject { |v| v.to_package_name == package.name }
194
+
195
+
196
+ new_package = ParsePackwerk::Package.new(
197
+ name: package.name,
198
+ enforce_dependencies: package.enforce_dependencies,
199
+ enforce_privacy: package.enforce_privacy,
200
+ public_path: package.public_path,
201
+ metadata: package.metadata,
202
+ dependencies: morphed_dependencies,
203
+ config: package.config,
204
+ violations: morphed_violations
205
+ )
206
+ # add dependencies TO nested packages to top-level package
207
+ # add violations TO nested packages to top-level package
208
+ end
209
+ end
210
+
211
+ morphed_packages.reject { |p| nested_packages.keys.include?(p.name) }
28
212
  end
29
213
  end
metadata CHANGED
@@ -1,79 +1,23 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: visualize_packs
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.3.1
4
+ version: 0.5.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gusto Engineers
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-08-09 00:00:00.000000000 Z
11
+ date: 2023-08-16 00:00:00.000000000 Z
12
12
  dependencies:
13
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-specification
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
14
+ name: bundler
71
15
  requirement: !ruby/object:Gem::Requirement
72
16
  requirements:
73
17
  - - ">="
74
18
  - !ruby/object:Gem::Version
75
19
  version: '0'
76
- type: :runtime
20
+ type: :development
77
21
  prerelease: false
78
22
  version_requirements: !ruby/object:Gem::Requirement
79
23
  requirements:
@@ -81,55 +25,41 @@ dependencies:
81
25
  - !ruby/object:Gem::Version
82
26
  version: '0'
83
27
  - !ruby/object:Gem::Dependency
84
- name: ruby-graphviz
28
+ name: sorbet-runtime
85
29
  requirement: !ruby/object:Gem::Requirement
86
30
  requirements:
87
31
  - - ">="
88
32
  - !ruby/object:Gem::Version
89
33
  version: '0'
90
- type: :runtime
34
+ type: :development
91
35
  prerelease: false
92
36
  version_requirements: !ruby/object:Gem::Requirement
93
37
  requirements:
94
38
  - - ">="
95
39
  - !ruby/object:Gem::Version
96
40
  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
41
  - !ruby/object:Gem::Dependency
112
42
  name: rspec
113
43
  requirement: !ruby/object:Gem::Requirement
114
44
  requirements:
115
45
  - - "~>"
116
46
  - !ruby/object:Gem::Version
117
- version: '3.0'
47
+ version: '3.12'
118
48
  type: :development
119
49
  prerelease: false
120
50
  version_requirements: !ruby/object:Gem::Requirement
121
51
  requirements:
122
52
  - - "~>"
123
53
  - !ruby/object:Gem::Version
124
- version: '3.0'
54
+ version: '3.12'
125
55
  - !ruby/object:Gem::Dependency
126
- name: sorbet
56
+ name: packs-specification
127
57
  requirement: !ruby/object:Gem::Requirement
128
58
  requirements:
129
59
  - - ">="
130
60
  - !ruby/object:Gem::Version
131
61
  version: '0'
132
- type: :development
62
+ type: :runtime
133
63
  prerelease: false
134
64
  version_requirements: !ruby/object:Gem::Requirement
135
65
  requirements:
@@ -137,36 +67,32 @@ dependencies:
137
67
  - !ruby/object:Gem::Version
138
68
  version: '0'
139
69
  - !ruby/object:Gem::Dependency
140
- name: tapioca
70
+ name: parse_packwerk
141
71
  requirement: !ruby/object:Gem::Requirement
142
72
  requirements:
143
73
  - - ">="
144
74
  - !ruby/object:Gem::Version
145
- version: '0'
146
- type: :development
75
+ version: 0.20.0
76
+ type: :runtime
147
77
  prerelease: false
148
78
  version_requirements: !ruby/object:Gem::Requirement
149
79
  requirements:
150
80
  - - ">="
151
81
  - !ruby/object:Gem::Version
152
- version: '0'
82
+ version: 0.20.0
153
83
  description: A gem to visualize connections in a Ruby app that uses packs
154
84
  email:
155
85
  - dev@gusto.com
156
- executables: []
86
+ executables:
87
+ - visualize_packs
157
88
  extensions: []
158
89
  extra_rdoc_files: []
159
90
  files:
160
91
  - README.md
92
+ - bin/visualize_packs
93
+ - lib/graph.dot.erb
94
+ - lib/options.rb
161
95
  - 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
96
  homepage: https://github.com/rubyatscale/visualize_packs
171
97
  licenses:
172
98
  - MIT
@@ -1,17 +0,0 @@
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
@@ -1,29 +0,0 @@
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
@@ -1,54 +0,0 @@
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
@@ -1,28 +0,0 @@
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
@@ -1,170 +0,0 @@
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
@@ -1,56 +0,0 @@
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
@@ -1,27 +0,0 @@
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