Skip to content

Commit e4b7176

Browse files
authored
Support list of private constants (#19)
* restore tests from #5 * update validator to support private_constants setting * add private_constants property With this change, the `enforce_privacy` setting will support only four values: true, false, 'strict', and nil. The array of private constant names can now only be specified via a new setting called `private_constants`. If `enforce_privacy` is set to false or nil, these private constants are ignored by the checker. If `private_constants` is not specified or is an empty list, all constants in the package are assumed to be private unless they are defined in the public directory. * bump minor version
1 parent ac38558 commit e4b7176

File tree

10 files changed

+203
-15
lines changed

10 files changed

+203
-15
lines changed

Gemfile.lock

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -17,7 +17,7 @@ GIT
1717
PATH
1818
remote: .
1919
specs:
20-
packwerk-extensions (0.0.11)
20+
packwerk-extensions (0.1.0)
2121
packwerk (>= 2.2.1)
2222
railties (>= 6.0.0)
2323
sorbet-runtime

README.md

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,9 @@ Example:
5656
public_path: my/custom/path/
5757
```
5858

59+
### Using specific private constants
60+
Sometimes it is desirable to only enforce privacy on a subset of constants in a package. You can do so by defining a `private_constants` list in your package.yml. Note that `enforce_privacy` must be set to `true` or `'strict'` for this to work.
61+
5962
### Package Privacy violation
6063
A constant that is private to its package has been referenced from outside of the package. Constants are declared private in their package’s `package.yml`.
6164

lib/packwerk/privacy/checker.rb

Lines changed: 17 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,9 @@ def invalid_reference?(reference)
3030
return false if privacy_package.public_path?(reference.constant.location)
3131

3232
privacy_option = privacy_package.enforce_privacy
33-
!enforcement_disabled?(privacy_option)
33+
return false if enforcement_disabled?(privacy_option)
34+
35+
explicitly_private_constant?(reference.constant, explicitly_private_constants: privacy_package.private_constants)
3436
end
3537

3638
sig do
@@ -63,6 +65,20 @@ def message(reference)
6365

6466
private
6567

68+
sig do
69+
params(
70+
constant: ConstantContext,
71+
explicitly_private_constants: T::Array[String]
72+
).returns(T::Boolean)
73+
end
74+
def explicitly_private_constant?(constant, explicitly_private_constants:)
75+
return true if explicitly_private_constants.empty?
76+
77+
explicitly_private_constants.include?(constant.name) ||
78+
# nested constants
79+
explicitly_private_constants.any? { |epc| constant.name.start_with?("#{epc}::") }
80+
end
81+
6682
sig do
6783
params(privacy_option: T.nilable(T.any(T::Boolean, String, T::Array[String])))
6884
.returns(T::Boolean)

lib/packwerk/privacy/package.rb

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@ class Package < T::Struct
88

99
const :public_path, String
1010
const :user_defined_public_path, T.nilable(String)
11-
const :enforce_privacy, T.nilable(T.any(T::Boolean, String, T::Array[String]))
11+
const :enforce_privacy, T.nilable(T.any(T::Boolean, String))
12+
const :private_constants, T::Array[String]
1213

1314
sig { params(path: String).returns(T::Boolean) }
1415
def public_path?(path)
@@ -23,7 +24,8 @@ def from(package)
2324
Package.new(
2425
public_path: public_path_for(package),
2526
user_defined_public_path: user_defined_public_path(package),
26-
enforce_privacy: package.config['enforce_privacy']
27+
enforce_privacy: package.config['enforce_privacy'],
28+
private_constants: package.config['private_constants'] || []
2729
)
2830
end
2931

lib/packwerk/privacy/validator.rb

Lines changed: 91 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ def call(package_set, configuration)
1919
results << check_enforce_privacy_setting(config_file_path, setting)
2020
end
2121

22+
results += verify_private_constants_setting(package_set, configuration)
23+
2224
public_path_settings = package_manifests_settings_for(configuration, 'public_path')
2325
public_path_settings.each do |config_file_path, setting|
2426
results << check_public_path(config_file_path, setting)
@@ -29,11 +31,49 @@ def call(package_set, configuration)
2931

3032
sig { override.returns(T::Array[String]) }
3133
def permitted_keys
32-
%w[public_path enforce_privacy]
34+
%w[public_path enforce_privacy private_constants]
3335
end
3436

3537
private
3638

39+
sig { params(package_set: PackageSet, configuration: Configuration).returns(T::Array[Result]) }
40+
def verify_private_constants_setting(package_set, configuration)
41+
private_constants_setting = package_manifests_settings_for(configuration, 'private_constants')
42+
results = T.let([], T::Array[Result])
43+
resolver = ConstantResolver.new(
44+
root_path: configuration.root_path,
45+
load_paths: configuration.load_paths
46+
)
47+
48+
private_constants_setting.each do |config_file_path, setting|
49+
next if setting.nil?
50+
51+
unless setting.is_a?(Array)
52+
results << Result.new(
53+
ok: false,
54+
error_value: "Invalid 'private_constants' setting: #{setting.inspect}"
55+
)
56+
next
57+
end
58+
59+
constants = setting
60+
61+
results += assert_constants_can_be_loaded(constants, config_file_path)
62+
63+
constant_locations = constants.map { |c| [c, resolver.resolve(c)&.location] }
64+
65+
constant_locations.each do |name, location|
66+
results << if location
67+
check_private_constant_location(configuration, package_set, name, location, config_file_path)
68+
else
69+
private_constant_unresolvable(name, config_file_path)
70+
end
71+
end
72+
end
73+
74+
results
75+
end
76+
3777
sig do
3878
params(config_file_path: String, setting: T.untyped).returns(Result)
3979
end
@@ -52,7 +92,7 @@ def check_public_path(config_file_path, setting)
5292
params(config_file_path: String, setting: T.untyped).returns(Result)
5393
end
5494
def check_enforce_privacy_setting(config_file_path, setting)
55-
if [TrueClass, FalseClass, Array, NilClass].include?(setting.class) || setting == 'strict'
95+
if [TrueClass, FalseClass, NilClass].include?(setting.class) || setting == 'strict'
5696
Result.new(ok: true)
5797
else
5898
Result.new(
@@ -61,6 +101,55 @@ def check_enforce_privacy_setting(config_file_path, setting)
61101
)
62102
end
63103
end
104+
105+
sig do
106+
params(configuration: Configuration, package_set: PackageSet, name: T.untyped, location: T.untyped,
107+
config_file_path: T.untyped).returns(Result)
108+
end
109+
def check_private_constant_location(configuration, package_set, name, location, config_file_path)
110+
declared_package = package_set.package_from_path(relative_path(configuration, config_file_path))
111+
constant_package = package_set.package_from_path(location)
112+
113+
if constant_package == declared_package
114+
Result.new(ok: true)
115+
else
116+
Result.new(
117+
ok: false,
118+
error_value: "'#{name}' is declared as private in the '#{declared_package}' package but appears to be " \
119+
"defined\nin the '#{constant_package}' package. Packwerk resolved it to #{location}."
120+
)
121+
end
122+
end
123+
124+
sig { params(constants: T.untyped, config_file_path: String).returns(T::Array[Result]) }
125+
def assert_constants_can_be_loaded(constants, config_file_path)
126+
constants.map do |constant|
127+
if constant.start_with?('::')
128+
constant.try(&:constantize) && Result.new(ok: true)
129+
else
130+
error_value = "'#{constant}', listed in the 'private_constants' option " \
131+
"in #{config_file_path}, is invalid.\nPrivate constants need to be " \
132+
'prefixed with the top-level namespace operator `::`.'
133+
Result.new(
134+
ok: false,
135+
error_value: error_value
136+
)
137+
end
138+
end
139+
end
140+
141+
sig { params(name: T.untyped, config_file_path: T.untyped).returns(Result) }
142+
def private_constant_unresolvable(name, config_file_path)
143+
explicit_filepath = "#{(name.start_with?('::') ? name[2..] : name).underscore}.rb"
144+
145+
Result.new(
146+
ok: false,
147+
error_value: "'#{name}', listed in #{config_file_path}, could not be resolved.\n" \
148+
"This is probably because it is an autovivified namespace - a namespace module that doesn't have a\n" \
149+
"file explicitly defining it. Packwerk currently doesn't support declaring autovivified namespaces as\n" \
150+
"private. Add a #{explicit_filepath} file to explicitly define the constant."
151+
)
152+
end
64153
end
65154
end
66155
end

packwerk-extensions.gemspec

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
Gem::Specification.new do |spec|
22
spec.name = 'packwerk-extensions'
3-
spec.version = '0.0.11'
3+
spec.version = '0.1.0'
44
spec.authors = ['Gusto Engineers']
55
spec.email = ['[email protected]']
66

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
1-
enforce_privacy:
1+
enforce_privacy: true
2+
private_constants:
23
- "::PrivateThing"

test/unit/privacy/checker_test.rb

Lines changed: 28 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,23 @@ class CheckerTest < Minitest::Test
1919
end
2020

2121
test 'ignores if destination package is not enforcing' do
22+
destination_package = Packwerk::Package.new(
23+
name: 'destination_package',
24+
config: { 'enforce_privacy' => false, 'private_constants' => ['::SomeName'] }
25+
)
2226
checker = privacy_checker
23-
reference = build_reference
27+
reference = build_reference(destination_package: destination_package)
28+
29+
refute checker.invalid_reference?(reference)
30+
end
31+
32+
test 'ignores if destination package is only enforcing for other constants' do
33+
destination_package = Packwerk::Package.new(
34+
name: 'destination_package',
35+
config: { 'enforce_privacy' => true, 'private_constants' => ['::SomeOtherConstant'] }
36+
)
37+
checker = privacy_checker
38+
reference = build_reference(destination_package: destination_package)
2439

2540
refute checker.invalid_reference?(reference)
2641
end
@@ -34,21 +49,29 @@ class CheckerTest < Minitest::Test
3449
end
3550

3651
test 'complains about private constant if enforcing for specific constants' do
37-
destination_package = Packwerk::Package.new(name: 'destination_package', config: { 'enforce_privacy' => ['::SomeName'] })
52+
destination_package = Packwerk::Package.new(name: 'destination_package', config: { 'enforce_privacy' => true, 'private_constants' => ['::SomeName'] })
3853
checker = privacy_checker
3954
reference = build_reference(destination_package: destination_package)
4055

4156
assert checker.invalid_reference?(reference)
4257
end
4358

4459
test 'complains about nested constant if enforcing for specific constants' do
45-
destination_package = Packwerk::Package.new(name: 'destination_package', config: { 'enforce_privacy' => ['::SomeName'] })
60+
destination_package = Packwerk::Package.new(name: 'destination_package', config: { 'enforce_privacy' => true, 'private_constants' => ['::SomeName'] })
4661
checker = privacy_checker
47-
reference = build_reference(destination_package: destination_package)
62+
reference = build_reference(destination_package: destination_package, constant_name: '::SomeName::Nested')
4863

4964
assert checker.invalid_reference?(reference)
5065
end
5166

67+
test 'ignores constant that starts like enforced constant' do
68+
destination_package = Packwerk::Package.new(name: 'destination_package', config: { 'enforce_privacy' => true, 'private_constants' => ['::SomeName'] })
69+
checker = privacy_checker
70+
reference = build_reference(destination_package: destination_package, constant_name: '::SomeNameButNotQuite')
71+
72+
refute checker.invalid_reference?(reference)
73+
end
74+
5275
test 'ignores public constant even if enforcing privacy for everything' do
5376
destination_package = Packwerk::Package.new(name: 'destination_package', config: { 'enforce_privacy' => true })
5477
checker = privacy_checker
@@ -58,7 +81,7 @@ class CheckerTest < Minitest::Test
5881
end
5982

6083
test 'only checks the package TODO file for private constants' do
61-
destination_package = Packwerk::Package.new(name: 'destination_package', config: { 'enforce_privacy' => ['::SomeName'] })
84+
destination_package = Packwerk::Package.new(name: 'destination_package', config: { 'enforce_privacy' => true, 'private_constants' => ['::SomeName'] })
6285
checker = privacy_checker
6386
reference = build_reference(destination_package: destination_package)
6487

test/unit/privacy/package_test.rb

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,7 +12,7 @@ class PackageTest < Minitest::Test
1212

1313
setup do
1414
setup_application_fixture
15-
@package = Packwerk::Package.new(name: 'components/timeline', config: { 'enforce_privacy' => ['::Test'] })
15+
@package = Packwerk::Package.new(name: 'components/timeline', config: { 'enforce_privacy' => true, 'private_constants' => ['::Test'] })
1616
end
1717

1818
teardown do
@@ -25,7 +25,7 @@ def privacy_package
2525
end
2626

2727
test '#enforce_privacy returns same value as from config' do
28-
assert_equal(['::Test'], privacy_package.enforce_privacy)
28+
assert_equal(true, privacy_package.enforce_privacy)
2929
end
3030

3131
test '#public_path returns expected path when using the default public path' do

test/unit/privacy/validator_test.rb

Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -49,6 +49,60 @@ class ValidatorTest < Minitest::Test
4949
assert_match(/'public_path' option must be a string/, result.error_value)
5050
end
5151

52+
test 'check_package_manifests_for_privacy returns an error for unresolvable privatized constants' do
53+
use_template(:skeleton)
54+
ConstantResolver.expects(:new).returns(stub('resolver', resolve: nil))
55+
56+
result = Packwerk::Privacy::Validator.new.call(package_set, config)
57+
refute result.ok?, result.error_value
58+
assert_match(
59+
/'::PrivateThing', listed in #{to_app_path('components\/timeline\/package.yml')}, could not be resolved/,
60+
result.error_value
61+
)
62+
assert_match(
63+
/Add a private_thing.rb file/,
64+
result.error_value
65+
)
66+
end
67+
68+
test 'check_package_manifests_for_privacy returns an error for privatized constants in other packages' do
69+
use_template(:skeleton)
70+
context = ConstantResolver::ConstantContext.new('::PrivateThing', 'private_thing.rb')
71+
72+
ConstantResolver.expects(:new).returns(stub('resolver', resolve: context))
73+
74+
result = Packwerk::Privacy::Validator.new.call(package_set, config)
75+
76+
refute result.ok?, result.error_value
77+
assert_match(
78+
%r{'::PrivateThing' is declared as private in the 'components/timeline' package},
79+
result.error_value
80+
)
81+
assert_match(
82+
/but appears to be defined\sin the '.' package/,
83+
result.error_value
84+
)
85+
end
86+
87+
test 'check_package_manifests_for_privacy returns an error for constants without `::` prefix' do
88+
use_template(:minimal)
89+
merge_into_app_yaml_file('package.yml', { 'private_constants' => ['::PrivateThing', 'OtherThing'] })
90+
91+
result = Packwerk::Privacy::Validator.new.call(package_set, config)
92+
93+
refute result.ok?, result.error_value
94+
assert_match(
95+
/'OtherThing', listed in the 'private_constants' option in .*package.yml, is invalid./,
96+
result.error_value
97+
)
98+
assert_match(
99+
/Private constants need to be prefixed with the top-level namespace operator `::`/,
100+
result.error_value
101+
)
102+
end
103+
104+
private
105+
52106
sig { returns(Packwerk::ApplicationValidator) }
53107
def validator
54108
@validator ||= Packwerk::ApplicationValidator.new

0 commit comments

Comments
 (0)