# typed: true # frozen_string_literal: true require "constant_resolver" require "pathname" require "yaml" module Packwerk # Checks the structure of the application and its packwerk configuration to make sure we can run a check and deliver # correct results. class ApplicationValidator def initialize(config_file_path:, configuration:, environment:) @config_file_path = config_file_path @configuration = configuration @environment = environment end Result = Struct.new(:ok?, :error_value) def check_all results = [ check_package_manifests_for_privacy, check_package_manifest_syntax, check_application_structure, check_acyclic_graph, check_package_manifest_paths, check_valid_package_dependencies, check_root_package_exists, ] merge_results(results) end def check_package_manifests_for_privacy privacy_settings = package_manifests_settings_for("enforce_privacy") resolver = ConstantResolver.new( root_path: @configuration.root_path, load_paths: @configuration.load_paths ) results = [] privacy_settings.each do |config_file_path, setting| next unless setting.is_a?(Array) constants = setting assert_constants_can_be_loaded(constants) constant_locations = constants.map { |c| [c, resolver.resolve(c)&.location] } constant_locations.each do |name, location| results << if location check_private_constant_location(name, location, config_file_path) else private_constant_unresolvable(name, config_file_path) end end end merge_results(results, separator: "\n---\n") end def check_package_manifest_syntax errors = [] package_manifests.each do |f| hash = YAML.load_file(f) next unless hash known_keys = %w(enforce_privacy enforce_dependencies public_path dependencies metadata) unknown_keys = hash.keys - known_keys unless unknown_keys.empty? errors << "Unknown keys in #{f}: #{unknown_keys.inspect}\n"\ "If you think a key should be included in your package.yml, please "\ "open an issue in https://github.com/Shopify/packwerk" end if hash.key?("enforce_privacy") unless [TrueClass, FalseClass, Array].include?(hash["enforce_privacy"].class) errors << "Invalid 'enforce_privacy' option in #{f.inspect}: #{hash["enforce_privacy"].inspect}" end end if hash.key?("enforce_dependencies") unless [TrueClass, FalseClass].include?(hash["enforce_dependencies"].class) errors << "Invalid 'enforce_dependencies' option in #{f.inspect}: #{hash["enforce_dependencies"].inspect}" end end if hash.key?("public_path") unless hash["public_path"].is_a?(String) errors << "'public_path' option must be a string in #{f.inspect}: #{hash["public_path"].inspect}" end end next unless hash.key?("dependencies") next if hash["dependencies"].is_a?(Array) errors << "Invalid 'dependencies' option in #{f.inspect}: #{hash["dependencies"].inspect}" end if errors.empty? Result.new(true) else Result.new(false, errors.join("\n---\n")) end end def check_application_structure resolver = ConstantResolver.new( root_path: @configuration.root_path.to_s, load_paths: @configuration.load_paths ) begin resolver.file_map Result.new(true) rescue => e Result.new(false, e.message) end end def check_acyclic_graph edges = package_set.flat_map do |package| package.dependencies.map { |dependency| [package, package_set.fetch(dependency)] } end dependency_graph = Packwerk::Graph.new(*T.unsafe(edges)) cycle_strings = build_cycle_strings(dependency_graph.cycles) if dependency_graph.acyclic? Result.new(true) else Result.new( false, <<~EOS Expected the package dependency graph to be acyclic, but it contains the following cycles: #{cycle_strings.join("\n")} EOS ) end end def check_package_manifest_paths all_package_manifests = package_manifests("**/") package_paths_package_manifests = package_manifests(package_glob) difference = all_package_manifests - package_paths_package_manifests if difference.empty? Result.new(true) else Result.new( false, <<~EOS Expected package paths for all package.ymls to be specified, but paths were missing for the following manifests: #{relative_paths(difference).join("\n")} EOS ) end end def check_valid_package_dependencies packages_dependencies = package_manifests_settings_for("dependencies") .delete_if { |_, deps| deps.nil? } packages_with_invalid_dependencies = packages_dependencies.each_with_object([]) do |(package, dependencies), invalid_packages| invalid_dependencies = dependencies.filter { |path| invalid_package_path?(path) } invalid_packages << [package, invalid_dependencies] if invalid_dependencies.any? end if packages_with_invalid_dependencies.empty? Result.new(true) else error_locations = packages_with_invalid_dependencies.map do |package, invalid_dependencies| package ||= @configuration.root_path package_path = Pathname.new(package).relative_path_from(@configuration.root_path) all_invalid_dependencies = invalid_dependencies.map { |d| " - #{d}" } <<~EOS #{package_path}: #{all_invalid_dependencies.join("\n")} EOS end Result.new( false, <<~EOS These dependencies do not point to valid packages: #{error_locations.join("\n")} EOS ) end end def check_root_package_exists root_package_path = File.join(@configuration.root_path, "package.yml") all_packages_manifests = package_manifests(package_glob) if all_packages_manifests.include?(root_package_path) Result.new(true) else Result.new( false, <<~EOS A root package does not exist. Create an empty `package.yml` at the root directory. EOS ) end end private # Convert the cycles: # # [[a, b, c], [b, c]] # # to the string: # # ["a -> b -> c -> a", "b -> c -> b"] def build_cycle_strings(cycles) cycles.map do |cycle| cycle_strings = cycle.map(&:to_s) cycle_strings << cycle.first.to_s "\t- #{cycle_strings.join(" → ")}" end end def package_manifests_settings_for(setting) package_manifests.map { |f| [f, (YAML.load_file(File.join(f)) || {})[setting]] } end def format_yaml_strings(list) list.sort.map { |p| "- \"#{p}\"" }.join("\n") end def package_glob @configuration.package_paths || "**" end def package_manifests(glob_pattern = package_glob) PackageSet.package_paths(@configuration.root_path, glob_pattern, @configuration.exclude) .map { |f| File.realpath(f) } end def relative_paths(paths) paths.map { |path| relative_path(path) } end def relative_path(path) Pathname.new(path).relative_path_from(@configuration.root_path) end def invalid_package_path?(path) # Packages at the root can be implicitly specified as "." return false if path == "." package_path = File.join(@configuration.root_path, path, Packwerk::PackageSet::PACKAGE_CONFIG_FILENAME) !File.file?(package_path) end def assert_constants_can_be_loaded(constants) constants.each(&:constantize) nil end def private_constant_unresolvable(name, config_file_path) explicit_filepath = (name.start_with?("::") ? name[2..-1] : name).underscore + ".rb" Result.new( false, "'#{name}', listed in #{config_file_path}, could not be resolved.\n"\ "This is probably because it is an autovivified namespace - a namespace module that doesn't have a\n"\ "file explicitly defining it. Packwerk currently doesn't support declaring autovivified namespaces as\n"\ "private. Add a #{explicit_filepath} file to explicitly define the constant." ) end def check_private_constant_location(name, location, config_file_path) declared_package = package_set.package_from_path(relative_path(config_file_path)) constant_package = package_set.package_from_path(location) if constant_package == declared_package Result.new(true) else Result.new( false, "'#{name}' is declared as private in the '#{declared_package}' package but appears to be "\ "defined\nin the '#{constant_package}' package. Packwerk resolved it to #{location}." ) end end def package_set @package_set ||= Packwerk::PackageSet.load_all_from(@configuration.root_path, package_pathspec: package_glob) end def merge_results(results, separator: "\n===\n", errors_headline: "") results.reject!(&:ok?) if results.empty? Result.new(true) else Result.new( false, errors_headline + results.map(&:error_value).join(separator) ) end end end end