Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions .rubocop.yml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
#
# See https://docs.rubocop.org/rubocop/configuration
AllCops:
SuggestExtensions: false
NewCops: enable
Exclude:
- vendor/bundle/**/**
Expand Down
2 changes: 1 addition & 1 deletion Gemfile.lock
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ GIT
PATH
remote: .
specs:
packwerk-extensions (0.0.5)
packwerk-extensions (0.0.6)
packwerk (>= 2.2.1)
rails
sorbet-runtime
Expand Down
22 changes: 21 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,9 +2,10 @@

`packwerk-extensions` is a home for checker extensions for packwerk.

Currently, it ships with two checkers to help improve the boundaries between packages. These checkers are:
Currently, it ships the following checkers to help improve the boundaries between packages. These checkers are:
- A `privacy` checker that ensures other packages are using your package's public API
- A `visibility` checker that allows packages to be private except to an explicit group of other packages.
- An experimental `architecture` checker that allows packages to specify their "layer" and requires that each layer only communicate with layers below it.

## Privacy Checker
The privacy checker extension was originally extracted from [packwerk](https://github.com/Shopify/packwerk).
Expand Down Expand Up @@ -62,3 +63,22 @@ enforce_visibility: true
visible_to:
- components/other_package
```

## Architecture Checker
The architecture checker can be used to enforce constraints on what can depend on what.

To enforce architecture for your package, first define the `architecture_layers` in `packwerk.yml`, for example:
```
architecture_layers:
- package
- utility
```

Then, turn on the checker in your package:
```yaml
# components/merchandising/package.yml
enforce_architecture: true
layer: utility
```

Now this pack can only depend on other utility packages.
4 changes: 4 additions & 0 deletions lib/packwerk-extensions.rb
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,10 @@
require 'packwerk/visibility/package'
require 'packwerk/visibility/validator'

require 'packwerk/architecture/checker'
require 'packwerk/architecture/package'
require 'packwerk/architecture/validator'

module Packwerk
module Extensions
end
Expand Down
96 changes: 96 additions & 0 deletions lib/packwerk/architecture/checker.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
# typed: strict
# frozen_string_literal: true

require 'packwerk/architecture/layers'

module Packwerk
module Architecture
# This enforces "layered architecture," which allows each class to be designated as one of N layers
# configured by the client in `packwerk.yml`, for example:
#
# architecture_layers:
# - orchestrator
# - business_domain
# - platform
# - utility
# - specification
#
# Then a package can configure:
# enforce_architecture: true | false | strict
# layer: utility
#
# This is intended to provide:
# A) Direction for which dependency violations to tackle
# B) What dependencies should or should not exist
# C) A potential sequencing for modularizing a system (starting with lower layers first).
#
class Checker
extend T::Sig
include Packwerk::Checker

VIOLATION_TYPE = T.let('architecture', String)

sig { override.returns(String) }
def violation_type
VIOLATION_TYPE
end

sig do
override
.params(reference: Packwerk::Reference)
.returns(T::Boolean)
end
def invalid_reference?(reference)
constant_package = Package.from(reference.constant.package)
referencing_package = Package.from(reference.package)
!referencing_package.can_depend_on?(constant_package, layers: layers)
end

sig do
override
.params(listed_offense: Packwerk::ReferenceOffense)
.returns(T::Boolean)
end
def strict_mode_violation?(listed_offense)
constant_package = listed_offense.reference.package
constant_package.config['enforce_architecture'] == 'strict'
end

sig do
override
.params(reference: Packwerk::Reference)
.returns(String)
end
def message(reference)
constant_package = Package.from(reference.constant.package)
referencing_package = Package.from(reference.package)

message = <<~MESSAGE
Architecture layer violation: '#{reference.constant.name}' belongs to '#{reference.constant.package}', whose architecture layer type is "#{constant_package.layer}."
This constant cannot be referenced by '#{reference.package}', whose architecture layer type is "#{referencing_package.layer}."
Can we organize our code logic to respect the layers of these packs? See all layers in packwerk.yml.

#{standard_help_message(reference)}
MESSAGE

message.chomp
end

# TODO: Extract this out into a common helper, can call it StandardViolationHelpMessage.new(...) and implements .to_s
sig { params(reference: Reference).returns(String) }
def standard_help_message(reference)
standard_message = <<~MESSAGE.chomp
Inference details: this is a reference to #{reference.constant.name} which seems to be defined in #{reference.constant.location}.
To receive help interpreting or resolving this error message, see: https://github.com/Shopify/packwerk/blob/main/TROUBLESHOOT.md#Troubleshooting-violations
MESSAGE

standard_message.chomp
end

sig { returns(Layers) }
def layers
@layers ||= T.let(Layers.new, T.nilable(Packwerk::Architecture::Layers))
end
end
end
end
30 changes: 30 additions & 0 deletions lib/packwerk/architecture/layers.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
# typed: strict
# frozen_string_literal: true

module Packwerk
module Architecture
class Layers
extend T::Sig

sig { void }
def initialize
@names = T.let(@names, T.nilable(T::Array[String]))
end

sig { params(layer: String).returns(Integer) }
def index_of(layer)
index = names.reverse.find_index(layer)
if index.nil?
raise "Layer #{layer} not find, please run `bin/packwerk validate`"
end

index
end

sig { returns(T::Array[String]) }
def names
@names ||= YAML.load_file('packwerk.yml')['architecture_layers'] || []
end
end
end
end
49 changes: 49 additions & 0 deletions lib/packwerk/architecture/package.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,49 @@
# typed: strict
# frozen_string_literal: true

module Packwerk
module Architecture
class Package < T::Struct
extend T::Sig

const :layer, T.nilable(String)
const :enforcement_setting, T.nilable(T.any(T::Boolean, String, T::Array[String]))
const :config, T::Hash[T.untyped, T.untyped]

sig { returns(T::Boolean) }
def enforces?
enforcement_setting == true || enforcement_setting == 'strict'
end

sig { params(other_package: Package, layers: Layers).returns(T::Boolean) }
def can_depend_on?(other_package, layers:)
return true if !enforces?

flow_sensitive_layer = layer
flow_sensitive_other_layer = other_package.layer
return true if flow_sensitive_layer.nil?
return true if flow_sensitive_other_layer.nil?

layers.index_of(flow_sensitive_layer) >= layers.index_of(flow_sensitive_other_layer)
end

class << self
extend T::Sig

sig { params(package: ::Packwerk::Package).returns(Package) }
def from(package)
from_config(package.config)
end

sig { params(config: T::Hash[T.untyped, T.untyped]).returns(Package) }
def from_config(config)
Package.new(
layer: config['layer'],
enforcement_setting: config['enforce_architecture'],
config: config
)
end
end
end
end
end
111 changes: 111 additions & 0 deletions lib/packwerk/architecture/validator.rb
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
# typed: strict
# frozen_string_literal: true

module Packwerk
module Architecture
class Validator
extend T::Sig
include Packwerk::Validator

Result = Packwerk::Validator::Result

sig { override.params(package_set: PackageSet, configuration: Configuration).returns(Result) }
def call(package_set, configuration)
results = T.let([], T::Array[Result])

package_manifests(configuration).each do |f|
config = YAML.load_file(File.join(f))
next if !config

result = check_enforce_architecture_setting(f, config['enforce_architecture'])
results << result
next if !result.ok?

result = check_layer_setting(config, f, config['layer'])
results << result
next if !result.ok?

package = Package.from_config(config)
results += check_dependencies_setting(package_set, package, f)
end

merge_results(results, separator: "\n---\n")
end

sig { returns(Layers) }
def layers
@layers ||= T.let(Layers.new, T.nilable(Packwerk::Architecture::Layers))
end

sig { override.returns(T::Array[String]) }
def permitted_keys
%w[enforce_architecture layer]
end

sig do
params(package_set: PackageSet, package: Package, config_file_path: String).returns(T::Array[Result])
end
def check_dependencies_setting(package_set, package, config_file_path)
results = T.let([], T::Array[Result])
package.config.fetch('dependencies', []).each do |dependency|
other_packwerk_package = package_set.fetch(dependency)
next if other_packwerk_package.nil?

other_package = Package.from(other_packwerk_package)
next if package.can_depend_on?(other_package, layers: layers)

results << Result.new(
ok: false,
error_value: "Invalid 'dependencies' in #{config_file_path.inspect}. '#{config_file_path}' has a layer type of '#{package.layer},' which cannot rely on '#{other_packwerk_package.name},' which has a layer type of '#{other_package.layer}.' `architecture_layers` can be found in packwerk.yml."
)
end

results
end

sig do
params(config: T::Hash[T.untyped, T.untyped], config_file_path: String, layer: T.untyped).returns(Result)
end
def check_layer_setting(config, config_file_path, layer)
enforce_architecture = config['enforce_architecture']
enforce_architecture_enabled = !(enforce_architecture.nil? || enforce_architecture == false)
valid_layer = layer.nil? || layers.names.include?(layer)

if layer.nil? && enforce_architecture_enabled
Result.new(
ok: false,
error_value: "Invalid 'layer' option in #{config_file_path.inspect}: #{layer.inspect}. `layer` must be set if `enforce_architecture` is on."
)
elsif valid_layer
Result.new(ok: true)
else
Result.new(
ok: false,
error_value: "Invalid 'layer' option in #{config_file_path.inspect}: #{layer.inspect}. Must be one of #{layers.names.inspect}"
)
end
end

sig do
params(config_file_path: String, setting: T.untyped).returns(Result)
end
def check_enforce_architecture_setting(config_file_path, setting)
valid_value = [true, nil, false, 'strict'].include?(setting)
layers_set = layers.names.any?
if valid_value && layers_set
Result.new(ok: true)
elsif valid_value
Result.new(
ok: false,
error_value: "Cannot set 'enforce_architecture' option in #{config_file_path.inspect} until `architectural_layers` have been specified in `packwerk.yml`"
)
else
Result.new(
ok: false,
error_value: "Invalid 'enforce_architecture' option in #{config_file_path.inspect}: #{setting.inspect}"
)
end
end
end
end
end
2 changes: 1 addition & 1 deletion packwerk-extensions.gemspec
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
Gem::Specification.new do |spec|
spec.name = 'packwerk-extensions'
spec.version = '0.0.5'
spec.version = '0.0.6'
spec.authors = ['Gusto Engineers']
spec.email = ['dev@gusto.com']

Expand Down
2 changes: 1 addition & 1 deletion test/integration/extension_test.rb
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@ class ExtensionTest < Minitest::Test
test 'extension is properly loaded' do
use_template(:extended)
Packwerk::Checker.all
assert_equal(Packwerk::Checker.all.count, 3)
assert_equal(Packwerk::Checker.all.count, 4)
found_checker = Packwerk::Checker.all.any? do |checker|
T.unsafe(checker).is_a?(Packwerk::Privacy::Checker)
end
Expand Down
Loading