package_protections 1.2.0 → 2.0.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: 41803352508cd5d4ede0fe0caf1121516474fe3e95a030e4db7e0f05badfa9a2
4
- data.tar.gz: bc594511c3b3da35dcd9efcfba0f989928e074e0da429a8f0f538e0537eff5be
3
+ metadata.gz: 2e7bd9febdd0619b714c112033b8bf61958e04368e8fb0c0e5f3498de54e988c
4
+ data.tar.gz: a0f7c5f79f3eb1279d084045c115345424dd2acfa22954eed321d2cf220bd40b
5
5
  SHA512:
6
- metadata.gz: 352aa3ea22bf0d7cf765023c35ed88c6026fda2b52036aa73fbcf35e4aca1df52df24486ce028a238dcf50db89f2864f4080e77d6da52d9f7be7fa984cd70dc7
7
- data.tar.gz: 6bb8ab188d27a3656cd6979d00fcaf725165176b17a42704be114ef0bbfbe7150facf21ae44ebc3c1633ce5b21876fd9b8088179c6e702f8addd49f890d7e1e3
6
+ metadata.gz: b8ff41de5e829270999baa91dc8df0fb3e810c6e8be39358cdbc6fd188a9d79ddf138f19b935a13360106c2a5e6c8300edffdf5fedf71e39c52826d2a6050a9e
7
+ data.tar.gz: f255780ea5ed693e66edcd028b4b8247419c612afddd1790f53078ecec3ca274e456c6ed16e62a0d83158c853f2be25282250c5b40b93b069da504469d25acf8
@@ -8,9 +8,13 @@ module PackageProtections
8
8
  sig { params(protections: T::Array[ProtectionInterface]).void }
9
9
  attr_writer :protections
10
10
 
11
+ sig { params(globally_permitted_namespaces: T::Array[String]).void }
12
+ attr_writer :globally_permitted_namespaces
13
+
11
14
  sig { void }
12
15
  def initialize
13
16
  @protections = T.let(default_protections, T::Array[ProtectionInterface])
17
+ @globally_permitted_namespaces = T.let([], T::Array[String])
14
18
  end
15
19
 
16
20
  sig { returns(T::Array[ProtectionInterface]) }
@@ -18,9 +22,15 @@ module PackageProtections
18
22
  @protections
19
23
  end
20
24
 
25
+ sig { returns(T::Array[String]) }
26
+ def globally_permitted_namespaces
27
+ @globally_permitted_namespaces
28
+ end
29
+
21
30
  sig { void }
22
31
  def bust_cache!
23
32
  @protections = default_protections
33
+ @globally_permitted_namespaces = []
24
34
  end
25
35
 
26
36
  sig { returns(T::Array[ProtectionInterface]) }
@@ -17,12 +17,6 @@ module PackageProtections
17
17
  module Private
18
18
  extend T::Sig
19
19
 
20
- sig { returns(Private::Configuration) }
21
- def self.config
22
- @config = T.let(@config, T.nilable(Configuration))
23
- @config ||= Private::Configuration.new
24
- end
25
-
26
20
  sig do
27
21
  params(
28
22
  packages: T::Array[ParsePackwerk::Package],
@@ -88,27 +82,6 @@ module PackageProtections
88
82
  end
89
83
  end
90
84
 
91
- sig do
92
- params(
93
- package_names: T::Array[String],
94
- all_packages: T::Array[ParsePackwerk::Package]
95
- ).returns(T::Array[ParsePackwerk::Package])
96
- end
97
- def self.packages_for_names(package_names, all_packages)
98
- all_packages_indexed_by_name = {}
99
- all_packages.each { |package| all_packages_indexed_by_name[package.name] = package }
100
-
101
- package_names.map do |package_name|
102
- clean_pack_name = package_name.gsub(%r{/$}, '')
103
- package = all_packages_indexed_by_name[clean_pack_name]
104
- if package.nil?
105
- raise "Sorry, we couldn't find a package with name #{package_name}. Here are all of the package names we know about: #{all_packages.map(&:name).sort.inspect}"
106
- end
107
-
108
- package
109
- end
110
- end
111
-
112
85
  sig { params(root_pathname: Pathname).returns(String) }
113
86
  def self.rubocop_yml(root_pathname:)
114
87
  protected_packages = Dir.chdir(root_pathname) { all_protected_packages }
@@ -143,7 +116,7 @@ module PackageProtections
143
116
  def self.bust_cache!
144
117
  @protected_packages_indexed_by_name = nil
145
118
  @private_cop_config = nil
146
- config.bust_cache!
119
+ PackageProtections.config.bust_cache!
147
120
  end
148
121
 
149
122
  sig { params(identifier: Identifier).returns(T::Hash[T.untyped, T.untyped]) }
@@ -7,11 +7,15 @@ require_relative 'matchers'
7
7
 
8
8
  def get_resulting_rubocop
9
9
  write_file('config/default.yml', <<~YML.strip)
10
- <%= PackageProtections.rubocop_yml %>
10
+ <%= PackageProtections.rubocop_yml(root_pathname: Pathname.pwd) %>
11
11
  YML
12
12
  YAML.safe_load(ERB.new(File.read('config/default.yml')).result(binding))
13
13
  end
14
14
 
15
15
  RSpec.configure do |config|
16
16
  config.include ApplicationFixtureHelper
17
+
18
+ config.before do
19
+ PackageProtections.bust_cache!
20
+ end
17
21
  end
@@ -44,13 +44,19 @@ module PackageProtections
44
44
 
45
45
  sig { params(blk: T.proc.params(arg0: Private::Configuration).void).void }
46
46
  def configure(&blk)
47
- yield(Private.config)
47
+ yield(PackageProtections.config)
48
48
  end
49
49
  end
50
50
 
51
51
  sig { returns(T::Array[ProtectionInterface]) }
52
52
  def self.all
53
- Private.config.protections
53
+ config.protections
54
+ end
55
+
56
+ sig { returns(Private::Configuration) }
57
+ def self.config
58
+ @config = T.let(@config, T.nilable(Private::Configuration))
59
+ @config ||= Private::Configuration.new
54
60
  end
55
61
 
56
62
  #
@@ -116,16 +122,6 @@ module PackageProtections
116
122
  Private.private_cop_config(identifier)
117
123
  end
118
124
 
119
- sig do
120
- params(
121
- package_names: T::Array[String],
122
- all_packages: T::Array[ParsePackwerk::Package]
123
- ).returns(T::Array[ParsePackwerk::Package])
124
- end
125
- def self.packages_for_names(package_names, all_packages)
126
- Private.packages_for_names(package_names, all_packages)
127
- end
128
-
129
125
  sig { void }
130
126
  def self.bust_cache!
131
127
  Private.bust_cache!
@@ -0,0 +1,110 @@
1
+ # typed: strict
2
+
3
+ module RuboCop
4
+ module Cop
5
+ module PackageProtections
6
+ class NamespacedUnderPackageName < Base
7
+ #
8
+ # This is a private class that represents API that we would prefer to be available somehow in Zeitwerk.
9
+ #
10
+ class DesiredZeitwerkApi
11
+ extend T::Sig
12
+
13
+ class NamespaceContext < T::Struct
14
+ const :current_namespace, String
15
+ const :expected_namespace, String
16
+ const :expected_filepath, String
17
+ end
18
+
19
+ #
20
+ # For now, this API includes `package_for_path`
21
+ # If this were truly zeitwerk API, it wouldn't include any mention of packs and it would likely not need the package at all
22
+ # Since it could get the actual namespace without knowing anything about packs.
23
+ # However, we would need to pass to it the desired namespace based on the pack name for it to be able to suggest
24
+ # a desired filepath.
25
+ # Likely this means that our own cop should determine the desired namespace and pass that in
26
+ # and this can determine actual namespace and how to get to expected.
27
+ #
28
+ sig { params(relative_filename: String, package_for_path: ParsePackwerk::Package).returns(T.nilable(NamespaceContext)) }
29
+ def for_file(relative_filename, package_for_path)
30
+ package_name = package_for_path.name
31
+
32
+ # Zeitwerk establishes a standard convention by which namespaces are defined.
33
+ # The package protections namespace checker is coupled to a specific assumption about how auto-loading works.
34
+ #
35
+ # Namely, we expect the following autoload paths: `packs/**/app/**/`
36
+ # Examples:
37
+ # 1) `packs/package_1/app/public/package_1/my_constant.rb` produces constant `Package1::MyConstant`
38
+ # 2) `packs/package_1/app/services/package_1/my_service.rb` produces constant `Package1::MyService`
39
+ # 3) `packs/package_1/app/services/package_1.rb` produces constant `Package1`
40
+ # 4) `packs/package_1/app/public/package_1.rb` produces constant `Package1`
41
+ #
42
+ # Described another way, we expect any part of the directory labeled NAMESPACE to establish a portion of the fully qualified runtime constant:
43
+ # `packs/**/app/**/NAMESPACE1/NAMESPACE2/[etc]`
44
+ #
45
+ # Therefore, for our implementation, we substitute out the non-namespace producing portions of the filename to count the number of namespaces.
46
+ # Note this will *not work* properly in applications that have different assumptions about autoloading.
47
+
48
+ path_without_package_base = relative_filename.gsub(%r{#{package_name}/app/}, '')
49
+ if path_without_package_base.include?('concerns')
50
+ autoload_folder_name = path_without_package_base.split('/').first(2).join('/')
51
+ else
52
+ autoload_folder_name = path_without_package_base.split('/').first
53
+ end
54
+
55
+ remaining_file_path = path_without_package_base.gsub(%r{\A#{autoload_folder_name}/}, '')
56
+ actual_namespace = get_actual_namespace(remaining_file_path, package_name)
57
+
58
+ if relative_filename.include?('app/')
59
+ app_or_lib = 'app'
60
+ elsif relative_filename.include?('lib/')
61
+ app_or_lib = 'lib'
62
+ end
63
+
64
+ absolute_desired_path = root_pathname.join(
65
+ package_name,
66
+ T.must(app_or_lib),
67
+ T.must(autoload_folder_name),
68
+ get_package_last_name(package_for_path),
69
+ remaining_file_path
70
+ )
71
+
72
+ relative_desired_path = absolute_desired_path.relative_path_from(root_pathname)
73
+
74
+ NamespaceContext.new(
75
+ current_namespace: actual_namespace,
76
+ expected_namespace: get_pack_based_namespace(package_for_path),
77
+ expected_filepath: relative_desired_path.to_s
78
+ )
79
+ end
80
+
81
+ sig { params(pack: ParsePackwerk::Package).returns(String) }
82
+ def get_pack_based_namespace(pack)
83
+ get_package_last_name(pack).camelize
84
+ end
85
+
86
+ private
87
+
88
+ sig { returns(Pathname) }
89
+ def root_pathname
90
+ Pathname.pwd
91
+ end
92
+
93
+ sig { params(pack: ParsePackwerk::Package).returns(String) }
94
+ def get_package_last_name(pack)
95
+ T.must(pack.name.split('/').last)
96
+ end
97
+
98
+ sig { params(remaining_file_path: String, package_name: String).returns(String) }
99
+ def get_actual_namespace(remaining_file_path, package_name)
100
+ # If the remaining file path is a ruby file (not a directory), then it establishes a global namespace
101
+ # Otherwise, per Zeitwerk's conventions as listed above, its a directory that establishes another global namespace
102
+ T.must(remaining_file_path.split('/').first).gsub('.rb', '').camelize
103
+ end
104
+ end
105
+
106
+ private_constant :DesiredZeitwerkApi
107
+ end
108
+ end
109
+ end
110
+ end
@@ -1,94 +1,109 @@
1
- # typed: true
1
+ # typed: strict
2
2
 
3
3
  # For String#camelize
4
4
  require 'active_support/core_ext/string/inflections'
5
+ require 'rubocop/cop/package_protections/namespaced_under_package_name/desired_zeitwerk_api'
5
6
 
6
7
  module RuboCop
7
8
  module Cop
8
9
  module PackageProtections
10
+ #
11
+ # TODO:
12
+ # This class is in serious need of being split up into helpful abstractions.
13
+ # A really helpful abstraction would be one that takes a file path and can spit out information about
14
+ # namespacing, such as the exposed namespace, the file path for a different namespace, and more.
15
+ #
9
16
  class NamespacedUnderPackageName < Base
10
17
  extend T::Sig
11
18
 
12
19
  include RangeHelp
13
20
  include ::PackageProtections::RubocopProtectionInterface
14
21
 
22
+ sig { void }
15
23
  def on_new_investigation
16
24
  absolute_filepath = Pathname.new(processed_source.file_path)
17
25
  relative_filepath = absolute_filepath.relative_path_from(Pathname.pwd)
18
26
  relative_filename = relative_filepath.to_s
19
27
 
20
- # This cop only works for files in `app`
21
- return if !relative_filename.include?('app/')
22
-
23
- match = relative_filename.match(%r{((#{::PackageProtections::EXPECTED_PACK_DIRECTORIES.join("|")})/.*?)/})
24
- package_name = match && match[1]
25
-
26
- return if package_name.nil?
27
-
28
- return if relative_filepath.extname != '.rb'
29
-
30
- # Zeitwerk establishes a standard convention by which namespaces are defined.
31
- # The package protections namespace checker is coupled to a specific assumption about how auto-loading works.
32
- #
33
- # Namely, we expect the following autoload paths: `packs/**/app/**/`
34
- # Examples:
35
- # 1) `packs/package_1/app/public/package_1/my_constant.rb` produces constant `Package1::MyConstant`
36
- # 2) `packs/package_1/app/services/package_1/my_service.rb` produces constant `Package1::MyService`
37
- # 3) `packs/package_1/app/services/package_1.rb` produces constant `Package1`
38
- # 4) `packs/package_1/app/public/package_1.rb` produces constant `Package1`
39
- #
40
- # Described another way, we expect any part of the directory labeled NAMESPACE to establish a portion of the fully qualified runtime constant:
41
- # `packs/**/app/**/NAMESPACE1/NAMESPACE2/[etc]`
42
- #
43
- # Therefore, for our implementation, we substitute out the non-namespace producing portions of the filename to count the number of namespaces.
44
- # Note this will *not work* properly in applications that have different assumptions about autoloading.
45
- package_last_name = T.must(package_name.split('/').last)
46
- path_without_package_base = relative_filename.gsub(%r{#{package_name}/app/}, '')
47
- if path_without_package_base.include?('concerns')
48
- autoload_folder_name = path_without_package_base.split('/').first(2).join('/')
49
- else
50
- autoload_folder_name = path_without_package_base.split('/').first
51
- end
28
+ # This cop only works for files ruby files in `app`
29
+ return if !relative_filename.include?('app/') || relative_filepath.extname != '.rb'
52
30
 
53
- remaining_file_path = path_without_package_base.gsub(%r{\A#{autoload_folder_name}/}, '')
54
- actual_namespace = get_actual_namespace(remaining_file_path, relative_filepath, package_name)
55
- allowed_namespaces = get_allowed_namespaces(package_name)
56
- if allowed_namespaces.include?(actual_namespace)
57
- # No problem!
58
- elsif allowed_namespaces.count == 1
59
- single_allowed_namespace = allowed_namespaces.first
60
- if relative_filepath.to_s.include?('app/')
61
- app_or_lib = 'app'
62
- elsif relative_filepath.to_s.include?('lib/')
63
- app_or_lib = 'lib'
64
- end
31
+ relative_filename = relative_filepath.to_s
32
+ package_for_path = ParsePackwerk.package_from_path(relative_filename)
33
+ return if package_for_path.nil?
65
34
 
66
- absolute_desired_path = root_pathname.join(package_name, app_or_lib, T.must(autoload_folder_name), single_allowed_namespace.underscore, remaining_file_path)
35
+ namespace_context = self.class.desired_zeitwerk_api.for_file(relative_filename, package_for_path)
36
+ return if namespace_context.nil?
67
37
 
68
- relative_desired_path = absolute_desired_path.relative_path_from(root_pathname)
38
+ allowed_global_namespaces = Set.new([
39
+ namespace_context.expected_namespace,
40
+ *::PackageProtections.config.globally_permitted_namespaces
41
+ ])
69
42
 
70
- add_offense(
71
- source_range(processed_source.buffer, 1, 0),
72
- message: format(
73
- '`%<package_name>s` prevents modules/classes that are not submodules of the package namespace. Should be namespaced under `%<expected_namespace>s` with path `%<expected_path>s`. See https://go/packwerk_cheatsheet_namespaces for more info.',
74
- package_name: package_name,
75
- expected_namespace: package_last_name.camelize,
76
- expected_path: relative_desired_path
77
- )
78
- )
43
+ package_name = package_for_path.name
44
+ actual_namespace = namespace_context.current_namespace
45
+
46
+ if allowed_global_namespaces.include?(actual_namespace)
47
+ # No problem!
79
48
  else
80
- add_offense(
81
- source_range(processed_source.buffer, 1, 0),
82
- message: format(
83
- '`%<package_name>s` prevents modules/classes that are not submodules of one of the allowed namespaces in `%<package_yml>s`. See https://go/packwerk_cheatsheet_namespaces for more info.',
84
- package_name: package_name,
85
- package_yml: "#{package_name}/package.yml"
49
+ package_enforces_namespaces = !::PackageProtections::ProtectedPackage.from(package_for_path).violation_behavior_for(NamespacedUnderPackageName::IDENTIFIER).fail_never?
50
+ expected_namespace = namespace_context.expected_namespace
51
+ relative_desired_path = namespace_context.expected_filepath
52
+ pack_owning_this_namespace = self.class.namespaces_to_packs[actual_namespace]
53
+
54
+ if package_enforces_namespaces
55
+ add_offense(
56
+ source_range(processed_source.buffer, 1, 0),
57
+ message: format(
58
+ '`%<package_name>s` prevents modules/classes that are not submodules of the package namespace. Should be namespaced under `%<expected_namespace>s` with path `%<expected_path>s`. See https://go/packwerk_cheatsheet_namespaces for more info.',
59
+ package_name: package_name,
60
+ expected_namespace: expected_namespace,
61
+ expected_path: relative_desired_path
62
+ )
86
63
  )
87
- )
64
+ elsif pack_owning_this_namespace
65
+ add_offense(
66
+ source_range(processed_source.buffer, 1, 0),
67
+ message: format(
68
+ '`%<pack_owning_this_namespace>s` prevents other packs from sitting in the `%<actual_namespace>s` namespace. This should be namespaced under `%<expected_namespace>s` with path `%<expected_path>s`. See https://go/packwerk_cheatsheet_namespaces for more info.',
69
+ package_name: package_name,
70
+ pack_owning_this_namespace: pack_owning_this_namespace,
71
+ expected_namespace: expected_namespace,
72
+ actual_namespace: actual_namespace,
73
+ expected_path: relative_desired_path
74
+ )
75
+ )
76
+ end
88
77
  end
89
78
  end
90
79
 
91
- IDENTIFIER = 'prevent_this_package_from_creating_other_namespaces'.freeze
80
+ # We override `cop_configs` for this protection.
81
+ # The default behavior disables cops when a package has turned off a protection.
82
+ # However: namespace violations can occur even when one package has TURNED OFF their namespace protection
83
+ # but another package has it turned on. Therefore, all packages must always be opted in no matter what.
84
+ #
85
+ sig do
86
+ params(packages: T::Array[::PackageProtections::ProtectedPackage])
87
+ .returns(T::Array[::PackageProtections::RubocopProtectionInterface::CopConfig])
88
+ end
89
+ def cop_configs(packages)
90
+ include_paths = T.let([], T::Array[String])
91
+ packages.each do |p|
92
+ included_globs_for_pack.each do |glob|
93
+ include_paths << p.original_package.directory.join(glob).to_s
94
+ end
95
+ end
96
+
97
+ [
98
+ ::PackageProtections::RubocopProtectionInterface::CopConfig.new(
99
+ name: cop_name,
100
+ enabled: include_paths.any?,
101
+ include_paths: include_paths
102
+ )
103
+ ]
104
+ end
105
+
106
+ IDENTIFIER = T.let('prevent_this_package_from_creating_other_namespaces'.freeze, String)
92
107
 
93
108
  sig { override.returns(String) }
94
109
  def identifier
@@ -105,7 +120,11 @@ module RuboCop
105
120
 
106
121
  # The reason for this is precondition is the `MultipleNamespacesProtection` assumes this to work properly.
107
122
  # To remove this precondition, we need to modify `MultipleNamespacesProtection` to be more generalized!
108
- if ::PackageProtections::EXPECTED_PACK_DIRECTORIES.include?(Pathname.new(package.name).dirname.to_s) || package.name == ParsePackwerk::ROOT_PACKAGE_NAME
123
+ is_root_package = package.name == ParsePackwerk::ROOT_PACKAGE_NAME
124
+ in_allowed_directory = ::PackageProtections::EXPECTED_PACK_DIRECTORIES.any? do |expected_package_directory|
125
+ package.directory.to_s.start_with?(expected_package_directory)
126
+ end
127
+ if in_allowed_directory || is_root_package
109
128
  nil
110
129
  else
111
130
  "Package #{package.name} must be located in one of #{::PackageProtections::EXPECTED_PACK_DIRECTORIES.join(', ')} (or be the root) to use this protection"
@@ -121,15 +140,6 @@ module RuboCop
121
140
  ]
122
141
  end
123
142
 
124
- sig do
125
- params(package: ::PackageProtections::ProtectedPackage).returns(T::Hash[T.untyped, T.untyped])
126
- end
127
- def custom_cop_config(package)
128
- {
129
- 'GlobalNamespaces' => package.metadata['global_namespaces']
130
- }
131
- end
132
-
133
143
  sig do
134
144
  override.params(file: String).returns(String)
135
145
  end
@@ -159,22 +169,29 @@ module RuboCop
159
169
  MESSAGE
160
170
  end
161
171
 
162
- private
163
-
164
- def get_allowed_namespaces(package_name)
165
- cop_config = ::PackageProtections.private_cop_config('prevent_this_package_from_creating_other_namespaces')
166
- allowed_global_namespaces = cop_config[package_name]['GlobalNamespaces'] || [package_name.split('/').last.camelize]
167
- Set.new(allowed_global_namespaces)
172
+ sig { returns(DesiredZeitwerkApi) }
173
+ def self.desired_zeitwerk_api
174
+ # This is cached at the class level so we will cache more expensive operations
175
+ # across rubocop requests.
176
+ @desired_zeitwerk_api ||= T.let(nil, T.nilable(DesiredZeitwerkApi))
177
+ @desired_zeitwerk_api ||= DesiredZeitwerkApi.new
168
178
  end
169
179
 
170
- def get_actual_namespace(remaining_file_path, relative_filepath, package_name)
171
- # If the remaining file path is a ruby file (not a directory), then it establishes a global namespace
172
- # Otherwise, per Zeitwerk's conventions as listed above, its a directory that establishes another global namespace
173
- remaining_file_path.split('/').first.gsub('.rb', '').camelize
174
- end
180
+ sig { returns(T::Hash[String, String]) }
181
+ def self.namespaces_to_packs
182
+ @namespaces_to_packs = T.let(nil, T.nilable(T::Hash[String, String]))
183
+ @namespaces_to_packs ||= begin
184
+ all_packs_enforcing_namespaces = ParsePackwerk.all.reject do |p|
185
+ ::PackageProtections::ProtectedPackage.from(p).violation_behavior_for(NamespacedUnderPackageName::IDENTIFIER).fail_never?
186
+ end
187
+
188
+ namespaces_to_packs = {}
189
+ all_packs_enforcing_namespaces.each do |package|
190
+ namespaces_to_packs[desired_zeitwerk_api.get_pack_based_namespace(package)] = package.name
191
+ end
175
192
 
176
- def root_pathname
177
- Pathname.pwd
193
+ namespaces_to_packs
194
+ end
178
195
  end
179
196
  end
180
197
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: package_protections
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.2.0
4
+ version: 2.0.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: 2022-07-01 00:00:00.000000000 Z
11
+ date: 2022-09-12 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: activesupport
@@ -192,6 +192,7 @@ files:
192
192
  - lib/package_protections/rubocop_protection_interface.rb
193
193
  - lib/package_protections/violation_behavior.rb
194
194
  - lib/rubocop/cop/package_protections/namespaced_under_package_name.rb
195
+ - lib/rubocop/cop/package_protections/namespaced_under_package_name/desired_zeitwerk_api.rb
195
196
  - lib/rubocop/cop/package_protections/typed_public_api.rb
196
197
  homepage: https://github.com/rubyatscale/package_protections
197
198
  licenses: