# typed: strict # frozen_string_literal: true require "yaml" module Packwerk class PackageTodo extend T::Sig PackageName = T.type_alias { String } ConstantName = T.type_alias { String } FilePath = T.type_alias { String } Entry = T.type_alias { T::Hash[ConstantName, T::Hash[ConstantName, T::Array[FilePath]]] } Entries = T.type_alias do T::Hash[PackageName, Entry] end sig { params(package: Packwerk::Package, path: String).void } def initialize(package, path) @package = package @path = path @new_entries = T.let({}, Entries) @old_entries = T.let(nil, T.nilable(Entries)) end sig do params(reference: Packwerk::Reference, violation_type: String) .returns(T::Boolean) end def listed?(reference, violation_type:) violated_constants_found = old_entries.dig(reference.constant.package.name, reference.constant.name) return false unless violated_constants_found violated_constant_in_file = violated_constants_found.fetch("files", []).include?(reference.relative_path) return false unless violated_constant_in_file violated_constants_found.fetch("violations", []).include?(violation_type) end sig do params(reference: Packwerk::Reference, violation_type: String).returns(T::Boolean) end def add_entries(reference, violation_type) package_violations = new_entries.fetch(reference.constant.package.name, {}) entries_for_constant = package_violations[reference.constant.name] ||= {} entries_for_constant["violations"] ||= [] entries_for_constant.fetch("violations") << violation_type entries_for_constant["files"] ||= [] entries_for_constant.fetch("files") << reference.relative_path.to_s new_entries[reference.constant.package.name] = package_violations listed?(reference, violation_type: violation_type) end sig { params(for_files: T::Set[String]).returns(T::Boolean) } def stale_violations?(for_files) prepare_entries_for_dump old_entries.any? do |package, violations| files = for_files + deleted_files_for(package) violations_for_files = package_violations_for(violations, files: files) # We `next false` because if we cannot find existing violations for `for_files` within # the `package_todo.yml` file, then there are no violations that # can be considered stale. next false if violations_for_files.empty? stale_violation_for_package?(package, violations: violations_for_files) end end sig { void } def dump if new_entries.empty? delete_if_exists else prepare_entries_for_dump message = <<~MESSAGE # This file contains a list of dependencies that are not part of the long term plan for the # '#{@package.name}' package. # We should generally work to reduce this list over time. # # You can regenerate this file using the following command: # # bin/packwerk update-todo MESSAGE File.open(@path, "w") do |f| f.write(message) f.write(new_entries.to_yaml) end end end sig { void } def delete_if_exists File.delete(@path) if File.exist?(@path) end private sig { returns(Entries) } attr_reader(:new_entries) sig { params(package: String).returns(T::Array[String]) } def deleted_files_for(package) old_files = old_entries.fetch(package, {}).values.flat_map { |violation| violation.fetch("files") } new_files = new_entries.fetch(package, {}).values.flat_map { |violation| violation.fetch("files") } old_files - new_files end sig { params(package: String, violations: Entry).returns(T::Boolean) } def stale_violation_for_package?(package, violations:) violations.any? do |constant_name, entries_for_constant| new_entries_violation_types = new_entries.dig(package, constant_name, "violations") # If there are no NEW entries that match the old entries `for_files`, # new_entries is from the list of violations we get when we check this file. # If this list is empty, we also must have stale violations. next true if new_entries_violation_types.nil? if entries_for_constant.fetch("violations").all? { |type| new_entries_violation_types.include?(type) } stale_violations = entries_for_constant.fetch("files") - Array(new_entries.dig(package, constant_name, "files")) stale_violations.any? else return true end end end sig { params(package_violations: Entry, files: T::Set[String]).returns(Entry) } def package_violations_for(package_violations, files:) {}.tap do |package_violations_for_files| package_violations_for_files = T.cast(package_violations_for_files, Entry) package_violations.each do |constant_name, entries_for_constant| entries_for_files = files & entries_for_constant.fetch("files") next if entries_for_files.none? package_violations_for_files[constant_name] = { "violations" => entries_for_constant["violations"], "files" => entries_for_files.to_a, } end end end sig { returns(Entries) } def prepare_entries_for_dump new_entries.each do |package_name, package_violations| package_violations.each do |_, entries_for_constant| entries_for_constant.fetch("violations").sort!.uniq! entries_for_constant.fetch("files").sort!.uniq! end new_entries[package_name] = package_violations.sort.to_h end @new_entries = new_entries.sort.to_h end sig { returns(Entries) } def old_entries @old_entries ||= load_yaml_file(@path) end sig { params(path: String).returns(Entries) } def load_yaml_file(path) File.exist?(path) && YAML.load_file(path) || {} rescue Psych::Exception {} end end end