package_protections 1.4.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: 7b99c93d81087956b947c89fbede35844a2c973f20e2bee3c8f474383ff99a88
4
- data.tar.gz: 64ad87cf10597e5fceeefa315dd3e665f7a684ac36db30c206012c15c2a0f3f2
3
+ metadata.gz: 2e7bd9febdd0619b714c112033b8bf61958e04368e8fb0c0e5f3498de54e988c
4
+ data.tar.gz: a0f7c5f79f3eb1279d084045c115345424dd2acfa22954eed321d2cf220bd40b
5
5
  SHA512:
6
- metadata.gz: 9a5a70b90e8ecf6a00e8b22db867e6cec3a83ba34a8fa6f18b45449c96f3eb9fd248c80f9a047d9462e2976d233c8f6b29957837feef224a94d8ce93e008a55d
7
- data.tar.gz: 500e49a77a3ee17aa2c4cb05edf2806e37010b5168ebb9cf1a13e1addbb5db5eb11c0caaa82f4a10451dbf96d304c7e010400b19ba7240b920b29a4c93780a90
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],
@@ -122,7 +116,7 @@ module PackageProtections
122
116
  def self.bust_cache!
123
117
  @protected_packages_indexed_by_name = nil
124
118
  @private_cop_config = nil
125
- config.bust_cache!
119
+ PackageProtections.config.bust_cache!
126
120
  end
127
121
 
128
122
  sig { params(identifier: Identifier).returns(T::Hash[T.untyped, T.untyped]) }
@@ -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
  #
@@ -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,92 +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
- package_name = ParsePackwerk.package_from_path(relative_filename)&.name
24
- return if package_name.nil?
25
-
26
- return if relative_filepath.extname != '.rb'
27
-
28
- # Zeitwerk establishes a standard convention by which namespaces are defined.
29
- # The package protections namespace checker is coupled to a specific assumption about how auto-loading works.
30
- #
31
- # Namely, we expect the following autoload paths: `packs/**/app/**/`
32
- # Examples:
33
- # 1) `packs/package_1/app/public/package_1/my_constant.rb` produces constant `Package1::MyConstant`
34
- # 2) `packs/package_1/app/services/package_1/my_service.rb` produces constant `Package1::MyService`
35
- # 3) `packs/package_1/app/services/package_1.rb` produces constant `Package1`
36
- # 4) `packs/package_1/app/public/package_1.rb` produces constant `Package1`
37
- #
38
- # Described another way, we expect any part of the directory labeled NAMESPACE to establish a portion of the fully qualified runtime constant:
39
- # `packs/**/app/**/NAMESPACE1/NAMESPACE2/[etc]`
40
- #
41
- # Therefore, for our implementation, we substitute out the non-namespace producing portions of the filename to count the number of namespaces.
42
- # Note this will *not work* properly in applications that have different assumptions about autoloading.
43
- package_last_name = T.must(package_name.split('/').last)
44
- path_without_package_base = relative_filename.gsub(%r{#{package_name}/app/}, '')
45
- if path_without_package_base.include?('concerns')
46
- autoload_folder_name = path_without_package_base.split('/').first(2).join('/')
47
- else
48
- autoload_folder_name = path_without_package_base.split('/').first
49
- end
28
+ # This cop only works for files ruby files in `app`
29
+ return if !relative_filename.include?('app/') || relative_filepath.extname != '.rb'
50
30
 
51
- remaining_file_path = path_without_package_base.gsub(%r{\A#{autoload_folder_name}/}, '')
52
- actual_namespace = get_actual_namespace(remaining_file_path, relative_filepath, package_name)
53
- allowed_namespaces = get_allowed_namespaces(package_name)
54
- if allowed_namespaces.include?(actual_namespace)
55
- # No problem!
56
- elsif allowed_namespaces.count == 1
57
- single_allowed_namespace = allowed_namespaces.first
58
- if relative_filepath.to_s.include?('app/')
59
- app_or_lib = 'app'
60
- elsif relative_filepath.to_s.include?('lib/')
61
- app_or_lib = 'lib'
62
- 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?
63
34
 
64
- 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?
65
37
 
66
- 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
+ ])
67
42
 
68
- add_offense(
69
- source_range(processed_source.buffer, 1, 0),
70
- message: format(
71
- '`%<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.',
72
- package_name: package_name,
73
- expected_namespace: package_last_name.camelize,
74
- expected_path: relative_desired_path
75
- )
76
- )
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!
77
48
  else
78
- add_offense(
79
- source_range(processed_source.buffer, 1, 0),
80
- message: format(
81
- '`%<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.',
82
- package_name: package_name,
83
- 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
+ )
84
63
  )
85
- )
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
77
+ end
78
+ end
79
+
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
86
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
+ ]
87
104
  end
88
105
 
89
- IDENTIFIER = 'prevent_this_package_from_creating_other_namespaces'.freeze
106
+ IDENTIFIER = T.let('prevent_this_package_from_creating_other_namespaces'.freeze, String)
90
107
 
91
108
  sig { override.returns(String) }
92
109
  def identifier
@@ -123,15 +140,6 @@ module RuboCop
123
140
  ]
124
141
  end
125
142
 
126
- sig do
127
- params(package: ::PackageProtections::ProtectedPackage).returns(T::Hash[T.untyped, T.untyped])
128
- end
129
- def custom_cop_config(package)
130
- {
131
- 'GlobalNamespaces' => package.metadata['global_namespaces']
132
- }
133
- end
134
-
135
143
  sig do
136
144
  override.params(file: String).returns(String)
137
145
  end
@@ -161,22 +169,29 @@ module RuboCop
161
169
  MESSAGE
162
170
  end
163
171
 
164
- private
165
-
166
- def get_allowed_namespaces(package_name)
167
- cop_config = ::PackageProtections.private_cop_config('prevent_this_package_from_creating_other_namespaces')
168
- allowed_global_namespaces = cop_config[package_name]['GlobalNamespaces'] || [package_name.split('/').last.camelize]
169
- 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
170
178
  end
171
179
 
172
- def get_actual_namespace(remaining_file_path, relative_filepath, package_name)
173
- # If the remaining file path is a ruby file (not a directory), then it establishes a global namespace
174
- # Otherwise, per Zeitwerk's conventions as listed above, its a directory that establishes another global namespace
175
- remaining_file_path.split('/').first.gsub('.rb', '').camelize
176
- 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
177
192
 
178
- def root_pathname
179
- Pathname.pwd
193
+ namespaces_to_packs
194
+ end
180
195
  end
181
196
  end
182
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.4.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-08-13 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: