PackageProtections

This gem helps us use Packwerk and Rubocop to create well-packaged code. The intent of this gem is two fold: 1) Provide a coherent modularization interface, where each package.yml is the main place you go to configure modularization checks. 2) Create hard-checks for packwerk and rubocop. Packwerk and rubocop support gradual adoption, but they don't support the ability to block adding to the TODO list once a package has fully adhered to a rule.

This gem ships with the following checks 1) Your package is not introducing dependencies that are not intended (via packwerk enforce_dependencies) 2) Other packages are not using the private API of your package (via packwerk enforce_privacy) 3) Your package has a typed public API (via the rubocop PackageProtections/TypedPublicApi cop) 4) Your package only creates a single namespace (via the rubocop PackageProtections/NamespacedUnderPackageName cop)

Initial Configuration

Package protections first requires that your application is using packwerk, rubocop, and rubocop-sorbet. Follow the regular setup instructions for those tools before proceeding.

Some of our package protections are implemented by rubocop, with their interface in package.yml files. For initial configuration in a new application, you need to tell RuboCop to load the package protections extension:

# `.rubocop.yml`
inherit_gem:
  package_protections:
    - config/default.yml

require:
  - package_protections

Usage

Today, PackageProtections has several built-in protections that you can configure to protect your package.

By default, all protections are set to fail on new violations. Users need to specifically "opt out" if they do not want a protection. We want this because we want default behavior to be our vision for well-protected packages, and deviations from the ideal vision should require explicit user action. Most protections set their default to fail_on_new instead of fail_on_any because we want to make it easy for users to split up packages into other ones and improve boundaries incrementally. We recommend packages for totally greenfield features use the fail_on_any behavior.

Lastly, note that unless a protection's default behavior is fail_never, the protection must explicitly be set.

To change the behavior for these protections, add the correct YAML key under metadata.protections. See Example Usage below for an example.

prevent_this_package_from_violating_its_stated_dependencies

This is only available if your package has enforce_dependencies set to true! This protection ensures that your package does not use API from packages that are not listed under dependencies in package.yml. This helps make sure you manage your dependencies.

prevent_other_packages_from_using_this_packages_internals

This is only available if your package has enforce_privacy set to true! This protection ensures that OTHER packages do not use the private API of your package. This helps ensure that clients are using your code the way you intend.

prevent_this_package_from_exposing_an_untyped_api

This protection ensures that all files within app/public are typed at level strict, which means that every file must have a type signature. See https://sorbet.org/docs/static#file-level-granularity-strictness-levels for more information on typed strictness levels. Make sure to generate a TODO list if you want to use the fail_on_new violation behavior. See more information on generating a TODO list in the fail_on_new subsection under violation behaviors.

prevent_this_package_from_creating_other_namespaces

This is only available if your package is in ./packs, ./gems, ./components, or ./packages. This helps ensure that your package is only creating one namespace (based on folder hierarchy). This helps organize the public API of your pack into one place. This protection only looks at files in packs/your_pack/app (it ignores spec files). This protection is implemented via Rubocop -- expect to see results for this when running rubocop however you normally do. To add to the TODO list, add to .rubocop_todo.yml Lastly – this protection can be configured by setting globally_permitted_namespaces, e.g.:

RuboCop::Packs.configure do |config|
  config.globally_permitted_namespaces = ['SomeGlobalNamespace']
end

If you've worked through all of the TODOs for this cop and are able to set the value to fail_on_any, you can also set automatic_pack_namespace which will support your pack having one global namespace without extra subdirectories. That is, instead of packs/foo/app/services/foo/bar.rb, you can use packs/foo/app/services/bar.rb and still have it define Foo::Bar. See the stimpack README.md for more information.

Violation Behaviors

fail_on_any

If this behavior is selected, the build will fail if there is any issue, new or old.

fail_on_new

For protections from packwerk

If this behavior is selected, everything that is already in deprecated_references.yml is considered allowed. Think of it like .rubocop_todo.yml. If your PR introduces a new violation that is not captured in deprecated_references.yml, the build will rerun bin/packwerk check and fail if a new violation shows up. If for whatever reason you'd like to allow for the new violation, you can simply run bin/packwerk update-deprecations locally and commit the changes to deprecated_references.yml files.

For protections from rubocop

Similar to above, but instead of deprecated_references.yml, violations are stored in your .rubocop_todo.yml file. You can add to that file to bypass protections at this level.

fail_never

If this behavior is selected, the protection will not be active.

Example Usage

This is an example package that is focused on having a typed API that respects other teams' stated boundaries.

enforce_dependencies: true
enforce_privacy: true
metadata:
  protections:
    prevent_this_package_from_violating_its_stated_dependencies: fail_never
    prevent_other_packages_from_using_this_packages_internals: fail_never
    prevent_this_package_from_exposing_an_untyped_api: fail_on_any
    prevent_this_package_from_creating_other_namespaces: fail_never

PackageProtections.set_defaults!

Calling PackageProtections.set_defaults!(...) will make sure that all available protections are set in the protections metadata key without changing any protection behaviors that are already set.

Example Usage

# get your packages
packages = ParsePackwerk.all
# then set defaults
PackageProtections.set_defaults!(packages)
# or just set defaults for one package
PackageProtections.set_defaults!(packages.select{|p| p.package_name == 'packs/my_package'})

Custom Protections

It's possible to create your own custom protections that go through this interface. To do this, you just need to implement a protection and configure PackageProtections.

PackageProtections.configure do |config|
  config.protections += [MyCustomProtection]
end

In this example, MyCustomProtection needs to implement the PackageProtections::ProtectionInterface (for protections powered by packwerk that look at new and existing violations) OR PackageProtections::RubocopProtectionInterface (for protections powered by rubocop that look at the AST). It's recommended to take a look at the existing protections as examples. If you're having any trouble with this, please file an issue and we'll be glad to help.

Incorporating into your CI Pipeline

Your CI pipeline can execute the public API and fail if there are any offenses.

Discussions, Issues, Questions, and More

To keep things organized, here are some recommended homes:

Issues:

https://github.com/rubyatscale/package_protections/issues

Questions:

https://github.com/rubyatscale/package_protections/discussions/categories/q-a

General discussions:

https://github.com/rubyatscale/package_protections/discussions/categories/general

Ideas, new features, requests for change:

https://github.com/rubyatscale/package_protections/discussions/categories/ideas

Showcasing your work:

https://github.com/rubyatscale/package_protections/discussions/categories/show-and-tell