code_ownership 1.31.1 → 1.32.0

Sign up to get free protection for your applications and to get access to all the features.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5f18626234130fdbd4637847307299048bed10e57619e9fa695f94d05e22f398
4
- data.tar.gz: 07a11ca7265a02ab547501072867cffc9f4d2b3e194de9f919deb829dc240ad5
3
+ metadata.gz: 62401f4e5d0f4c94669144b049a2e47f0f436b0dec4977db339827aa2378afb6
4
+ data.tar.gz: be8561814a7c648b659b15fbe5c860edd409d3aa5aef91c1818820d447537a57
5
5
  SHA512:
6
- metadata.gz: d742401692975e16d26305c0fdd22671c34b9d67172da3ffc5b38846ec512761229dd6cc5f4a19c86e9b2c95cddd1e96b1a4452aebe571c4ae2f534ea834d2bf
7
- data.tar.gz: c06b4fad39595de7d5e74492483a4d326358bfdc8604340abd54686ed0543283d570c34d3f051923f8a080b9a6b75fa037d75dcd9aed570df7ae6e9da60e42b6
6
+ metadata.gz: d75fed2acaf64ba6ae82b7692531ad3d4629745d48a5567cfe954851d0edcd623b619be3d2a0f89f52e01125029bb01f5c1983bc39e4294640410f3d52229bd5
7
+ data.tar.gz: 290e5caeef1ed81b45a048d55800ee1d2772418d25ff749fc3dab33eef3f85190e294b4e0d7082f51ac2590df458011b5a859ea2faddfc96eec9d238163bb3a6
data/README.md CHANGED
@@ -57,6 +57,17 @@ js_package_paths:
57
57
 
58
58
  This defaults `**/`, which makes it look for `package.json` files across your application.
59
59
 
60
+ ### Custom Ownership
61
+ To enable custom ownership, you can inject your own custom classes into `code_ownership`.
62
+ To do this, first create a class that adheres to the `CodeOwnership::Mapper` and/or `CodeOwnership::Validator` interface.
63
+ Then, in `config/code_ownership.yml`, you can require that file:
64
+ ```yml
65
+ require:
66
+ - ./lib/my_extension.rb
67
+ ```
68
+
69
+ Now, `bin/codeownership validate` will automatically include your new mapper and/or validator. See [`spec/lib/code_ownership/private/extension_loader_spec.rb](spec/lib/code_ownership/private/extension_loader_spec.rb) for an example of what this looks like.
70
+
60
71
  ## Usage: Reading CodeOwnership
61
72
  ### `for_file`
62
73
  `CodeOwnership.for_file`, given a relative path to a file returns a `CodeTeams::Team` if there is a team that owns the file, `nil` otherwise.
@@ -0,0 +1,44 @@
1
+ # typed: strict
2
+
3
+ module CodeOwnership
4
+ class Configuration < T::Struct
5
+ extend T::Sig
6
+ DEFAULT_JS_PACKAGE_PATHS = T.let(['**/'], T::Array[String])
7
+
8
+ const :owned_globs, T::Array[String]
9
+ const :unowned_globs, T::Array[String]
10
+ const :js_package_paths, T::Array[String]
11
+ const :unbuilt_gems_path, T.nilable(String)
12
+ const :skip_codeowners_validation, T::Boolean
13
+ const :raw_hash, T::Hash[T.untyped, T.untyped]
14
+
15
+ sig { returns(Configuration) }
16
+ def self.fetch
17
+ config_hash = YAML.load_file('config/code_ownership.yml')
18
+
19
+ if config_hash.key?("require")
20
+ config_hash["require"].each do |require_directive|
21
+ Private::ExtensionLoader.load(require_directive)
22
+ end
23
+ end
24
+
25
+ new(
26
+ owned_globs: config_hash.fetch('owned_globs', []),
27
+ unowned_globs: config_hash.fetch('unowned_globs', []),
28
+ js_package_paths: js_package_paths(config_hash),
29
+ skip_codeowners_validation: config_hash.fetch('skip_codeowners_validation', false),
30
+ raw_hash: config_hash
31
+ )
32
+ end
33
+
34
+ sig { params(config_hash: T::Hash[T.untyped, T.untyped]).returns(T::Array[String]) }
35
+ def self.js_package_paths(config_hash)
36
+ specified_package_paths = config_hash['js_package_paths']
37
+ if specified_package_paths.nil?
38
+ DEFAULT_JS_PACKAGE_PATHS.dup
39
+ else
40
+ Array(specified_package_paths)
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,62 @@
1
+ # frozen_string_literal: true
2
+
3
+ # typed: strict
4
+
5
+ module CodeOwnership
6
+ module Mapper
7
+ extend T::Sig
8
+ extend T::Helpers
9
+
10
+ interface!
11
+
12
+ class << self
13
+ extend T::Sig
14
+
15
+ sig { params(base: Class).void }
16
+ def included(base)
17
+ @mappers ||= T.let(@mappers, T.nilable(T::Array[Class]))
18
+ @mappers ||= []
19
+ @mappers << base
20
+ end
21
+
22
+ sig { returns(T::Array[Mapper]) }
23
+ def all
24
+ T.unsafe(@mappers).map(&:new)
25
+ end
26
+ end
27
+
28
+ #
29
+ # This should be fast when run with ONE file
30
+ #
31
+ sig do
32
+ abstract.params(file: String).
33
+ returns(T.nilable(::CodeTeams::Team))
34
+ end
35
+ def map_file_to_owner(file)
36
+ end
37
+
38
+ #
39
+ # This should be fast when run with MANY files
40
+ #
41
+ sig do
42
+ abstract.params(files: T::Array[String]).
43
+ returns(T::Hash[String, T.nilable(::CodeTeams::Team)])
44
+ end
45
+ def map_files_to_owners(files)
46
+ end
47
+
48
+ sig do
49
+ abstract.returns(T::Hash[String, T.nilable(::CodeTeams::Team)])
50
+ end
51
+ def codeowners_lines_to_owners
52
+ end
53
+
54
+ sig { abstract.returns(String) }
55
+ def description
56
+ end
57
+
58
+ sig { abstract.void }
59
+ def bust_caches!
60
+ end
61
+ end
62
+ end
@@ -0,0 +1,24 @@
1
+ # typed: strict
2
+ # frozen_string_literal: true
3
+
4
+ module CodeOwnership
5
+ module Private
6
+ # This class handles loading extensions to code_ownership using the `require` directive
7
+ # in the `code_ownership.yml` configuration.
8
+ module ExtensionLoader
9
+ class << self
10
+ extend T::Sig
11
+ sig { params(require_directive: String).void }
12
+ def load(require_directive)
13
+ # We want to transform the require directive to behave differently
14
+ # if it's a specific local file being required versus a gem
15
+ if require_directive.start_with?(".")
16
+ require File.join(Pathname.pwd, require_directive)
17
+ else
18
+ require require_directive
19
+ end
20
+ end
21
+ end
22
+ end
23
+ end
24
+ end
@@ -16,7 +16,7 @@ module CodeOwnership
16
16
  # }
17
17
  class FileAnnotations
18
18
  extend T::Sig
19
- include Interface
19
+ include Mapper
20
20
 
21
21
  @@map_files_to_owners = T.let({}, T.nilable(T::Hash[String, T.nilable(::CodeTeams::Team)])) # rubocop:disable Style/ClassVars
22
22
 
@@ -7,7 +7,7 @@ module CodeOwnership
7
7
  module OwnershipMappers
8
8
  class JsPackageOwnership
9
9
  extend T::Sig
10
- include Interface
10
+ include Mapper
11
11
 
12
12
  @@package_json_cache = T.let({}, T::Hash[String, T.nilable(ParseJsPackages::Package)]) # rubocop:disable Style/ClassVars
13
13
 
@@ -7,7 +7,7 @@ module CodeOwnership
7
7
  module OwnershipMappers
8
8
  class PackageOwnership
9
9
  extend T::Sig
10
- include Interface
10
+ include Mapper
11
11
 
12
12
  @@package_yml_cache = T.let({}, T::Hash[String, T.nilable(Packs::Pack)]) # rubocop:disable Style/ClassVars
13
13
 
@@ -7,7 +7,8 @@ module CodeOwnership
7
7
  module OwnershipMappers
8
8
  class TeamGlobs
9
9
  extend T::Sig
10
- include Interface
10
+ include Mapper
11
+ include Validator
11
12
 
12
13
  @@map_files_to_owners = T.let(@map_files_to_owners, T.nilable(T::Hash[String, T.nilable(::CodeTeams::Team)])) # rubocop:disable Style/ClassVars
13
14
  @@map_files_to_owners = {} # rubocop:disable Style/ClassVars
@@ -114,6 +115,23 @@ module CodeOwnership
114
115
  def description
115
116
  'Team-specific owned globs'
116
117
  end
118
+
119
+ sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
120
+ def validation_errors(files:, autocorrect: true, stage_changes: true)
121
+ overlapping_globs = OwnershipMappers::TeamGlobs.new.find_overlapping_globs
122
+
123
+ errors = T.let([], T::Array[String])
124
+
125
+ if overlapping_globs.any?
126
+ errors << <<~MSG
127
+ `owned_globs` cannot overlap between teams. The following globs overlap:
128
+
129
+ #{overlapping_globs.map { |overlap| "- #{overlap.description}"}.join("\n")}
130
+ MSG
131
+ end
132
+
133
+ errors
134
+ end
117
135
  end
118
136
  end
119
137
  end
@@ -7,7 +7,7 @@ module CodeOwnership
7
7
  module OwnershipMappers
8
8
  class TeamYmlOwnership
9
9
  extend T::Sig
10
- include Interface
10
+ include Mapper
11
11
 
12
12
  @@map_files_to_owners = T.let(@map_files_to_owners, T.nilable(T::Hash[String, T.nilable(::CodeTeams::Team)])) # rubocop:disable Style/ClassVars
13
13
  @@map_files_to_owners = {} # rubocop:disable Style/ClassVars
@@ -6,7 +6,7 @@ module CodeOwnership
6
6
  class FilesHaveOwners
7
7
  extend T::Sig
8
8
  extend T::Helpers
9
- include Interface
9
+ include Validator
10
10
 
11
11
  sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
12
12
  def validation_errors(files:, autocorrect: true, stage_changes: true)
@@ -6,7 +6,7 @@ module CodeOwnership
6
6
  class FilesHaveUniqueOwners
7
7
  extend T::Sig
8
8
  extend T::Helpers
9
- include Interface
9
+ include Validator
10
10
 
11
11
  sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
12
12
  def validation_errors(files:, autocorrect: true, stage_changes: true)
@@ -6,7 +6,7 @@ module CodeOwnership
6
6
  class GithubCodeownersUpToDate
7
7
  extend T::Sig
8
8
  extend T::Helpers
9
- include Interface
9
+ include Validator
10
10
 
11
11
  sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
12
12
  def validation_errors(files:, autocorrect: true, stage_changes: true)
@@ -105,7 +105,7 @@ module CodeOwnership
105
105
  map[team.name] = team_github.team
106
106
  end
107
107
 
108
- Private.mappers.flat_map do |mapper|
108
+ Mapper.all.flat_map do |mapper|
109
109
  codeowners_lines = mapper.codeowners_lines_to_owners.filter_map do |line, team|
110
110
  team_mapping = github_team_map[team&.name]
111
111
  next unless team_mapping
@@ -2,16 +2,13 @@
2
2
 
3
3
  # typed: strict
4
4
 
5
- require 'code_ownership/private/configuration'
5
+ require 'code_ownership/private/extension_loader'
6
6
  require 'code_ownership/private/team_plugins/ownership'
7
7
  require 'code_ownership/private/team_plugins/github'
8
8
  require 'code_ownership/private/parse_js_packages'
9
- require 'code_ownership/private/validations/interface'
10
9
  require 'code_ownership/private/validations/files_have_owners'
11
10
  require 'code_ownership/private/validations/github_codeowners_up_to_date'
12
11
  require 'code_ownership/private/validations/files_have_unique_owners'
13
- require 'code_ownership/private/validations/no_overlapping_globs'
14
- require 'code_ownership/private/ownership_mappers/interface'
15
12
  require 'code_ownership/private/ownership_mappers/file_annotations'
16
13
  require 'code_ownership/private/ownership_mappers/team_globs'
17
14
  require 'code_ownership/private/ownership_mappers/package_ownership'
@@ -22,10 +19,10 @@ module CodeOwnership
22
19
  module Private
23
20
  extend T::Sig
24
21
 
25
- sig { returns(Private::Configuration) }
22
+ sig { returns(Configuration) }
26
23
  def self.configuration
27
- @configuration ||= T.let(@configuration, T.nilable(Private::Configuration))
28
- @configuration ||= Private::Configuration.fetch
24
+ @configuration ||= T.let(@configuration, T.nilable(Configuration))
25
+ @configuration ||= Configuration.fetch
29
26
  end
30
27
 
31
28
  sig { void }
@@ -37,14 +34,7 @@ module CodeOwnership
37
34
 
38
35
  sig { params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).void }
39
36
  def self.validate!(files:, autocorrect: true, stage_changes: true)
40
- validators = [
41
- Validations::FilesHaveOwners.new,
42
- Validations::FilesHaveUniqueOwners.new,
43
- Validations::GithubCodeownersUpToDate.new,
44
- Validations::NoOverlappingGlobs.new,
45
- ]
46
-
47
- errors = validators.flat_map do |validator|
37
+ errors = Validator.all.flat_map do |validator|
48
38
  validator.validation_errors(
49
39
  files: files,
50
40
  autocorrect: autocorrect,
@@ -58,23 +48,6 @@ module CodeOwnership
58
48
  end
59
49
  end
60
50
 
61
- sig { returns(T::Array[Private::OwnershipMappers::Interface]) }
62
- def self.mappers
63
- [
64
- file_annotations_mapper,
65
- Private::OwnershipMappers::TeamGlobs.new,
66
- Private::OwnershipMappers::PackageOwnership.new,
67
- Private::OwnershipMappers::JsPackageOwnership.new,
68
- Private::OwnershipMappers::TeamYmlOwnership.new,
69
- ]
70
- end
71
-
72
- sig { returns(Private::OwnershipMappers::FileAnnotations) }
73
- def self.file_annotations_mapper
74
- @file_annotations_mapper = T.let(@file_annotations_mapper, T.nilable(Private::OwnershipMappers::FileAnnotations))
75
- @file_annotations_mapper ||= Private::OwnershipMappers::FileAnnotations.new
76
- end
77
-
78
51
  # Returns a string version of the relative path to a Rails constant,
79
52
  # or nil if it can't find something
80
53
  sig { params(klass: T.nilable(T.any(Class, Module))).returns(T.nilable(String)) }
@@ -112,7 +85,7 @@ module CodeOwnership
112
85
  @files_by_mapper ||= begin
113
86
  files_by_mapper = files.map { |file| [file, []] }.to_h
114
87
 
115
- Private.mappers.each do |mapper|
88
+ Mapper.all.each do |mapper|
116
89
  mapper.map_files_to_owners(files).each do |file, _team|
117
90
  files_by_mapper[file] ||= []
118
91
  T.must(files_by_mapper[file]) << mapper.description
@@ -0,0 +1,30 @@
1
+ # typed: strict
2
+
3
+ module CodeOwnership
4
+ module Validator
5
+ extend T::Sig
6
+ extend T::Helpers
7
+
8
+ interface!
9
+
10
+ sig { abstract.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
11
+ def validation_errors(files:, autocorrect: true, stage_changes: true)
12
+ end
13
+
14
+ class << self
15
+ extend T::Sig
16
+
17
+ sig { params(base: Class).void }
18
+ def included(base)
19
+ @validators ||= T.let(@validators, T.nilable(T::Array[Class]))
20
+ @validators ||= []
21
+ @validators << base
22
+ end
23
+
24
+ sig { returns(T::Array[Validator]) }
25
+ def all
26
+ T.unsafe(@validators).map(&:new)
27
+ end
28
+ end
29
+ end
30
+ end
@@ -7,8 +7,11 @@ require 'code_teams'
7
7
  require 'sorbet-runtime'
8
8
  require 'json'
9
9
  require 'packs'
10
- require 'code_ownership/cli'
10
+ require 'code_ownership/mapper'
11
+ require 'code_ownership/validator'
11
12
  require 'code_ownership/private'
13
+ require 'code_ownership/cli'
14
+ require 'code_ownership/configuration'
12
15
 
13
16
  module CodeOwnership
14
17
  extend self
@@ -27,7 +30,7 @@ module CodeOwnership
27
30
 
28
31
  owner = T.let(nil, T.nilable(CodeTeams::Team))
29
32
 
30
- Private.mappers.each do |mapper|
33
+ Mapper.all.each do |mapper|
31
34
  owner = mapper.map_file_to_owner(file)
32
35
  break if owner
33
36
  end
@@ -41,7 +44,7 @@ module CodeOwnership
41
44
  ownership_information = T.let([], T::Array[String])
42
45
 
43
46
  ownership_information << "# Code Ownership Report for `#{team.name}` Team"
44
- Private.mappers.each do |mapper|
47
+ Mapper.all.each do |mapper|
45
48
  ownership_information << "## #{mapper.description}"
46
49
  codeowners_lines = mapper.codeowners_lines_to_owners
47
50
  ownership_for_mapper = []
@@ -69,7 +72,7 @@ module CodeOwnership
69
72
 
70
73
  sig { params(filename: String).void }
71
74
  def self.remove_file_annotation!(filename)
72
- Private.file_annotations_mapper.remove_file_annotation!(filename)
75
+ Private::OwnershipMappers::FileAnnotations.new.remove_file_annotation!(filename)
73
76
  end
74
77
 
75
78
  sig do
@@ -172,6 +175,11 @@ module CodeOwnership
172
175
  @for_file = nil
173
176
  @memoized_values = nil
174
177
  Private.bust_caches!
175
- Private.mappers.each(&:bust_caches!)
178
+ Mapper.all.each(&:bust_caches!)
179
+ end
180
+
181
+ sig { returns(Configuration) }
182
+ def self.configuration
183
+ Private.configuration
176
184
  end
177
185
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: code_ownership
3
3
  version: !ruby/object:Gem::Version
4
- version: 1.31.1
4
+ version: 1.32.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Gusto Engineers
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2023-03-06 00:00:00.000000000 Z
11
+ date: 2023-03-08 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: code_teams
@@ -134,10 +134,11 @@ files:
134
134
  - bin/codeownership
135
135
  - lib/code_ownership.rb
136
136
  - lib/code_ownership/cli.rb
137
+ - lib/code_ownership/configuration.rb
138
+ - lib/code_ownership/mapper.rb
137
139
  - lib/code_ownership/private.rb
138
- - lib/code_ownership/private/configuration.rb
140
+ - lib/code_ownership/private/extension_loader.rb
139
141
  - lib/code_ownership/private/ownership_mappers/file_annotations.rb
140
- - lib/code_ownership/private/ownership_mappers/interface.rb
141
142
  - lib/code_ownership/private/ownership_mappers/js_package_ownership.rb
142
143
  - lib/code_ownership/private/ownership_mappers/package_ownership.rb
143
144
  - lib/code_ownership/private/ownership_mappers/team_globs.rb
@@ -148,8 +149,7 @@ files:
148
149
  - lib/code_ownership/private/validations/files_have_owners.rb
149
150
  - lib/code_ownership/private/validations/files_have_unique_owners.rb
150
151
  - lib/code_ownership/private/validations/github_codeowners_up_to_date.rb
151
- - lib/code_ownership/private/validations/interface.rb
152
- - lib/code_ownership/private/validations/no_overlapping_globs.rb
152
+ - lib/code_ownership/validator.rb
153
153
  homepage: https://github.com/rubyatscale/code_ownership
154
154
  licenses:
155
155
  - MIT
@@ -1,37 +0,0 @@
1
- # typed: strict
2
-
3
- module CodeOwnership
4
- module Private
5
- class Configuration < T::Struct
6
- extend T::Sig
7
- DEFAULT_JS_PACKAGE_PATHS = T.let(['**/'], T::Array[String])
8
-
9
- const :owned_globs, T::Array[String]
10
- const :unowned_globs, T::Array[String]
11
- const :js_package_paths, T::Array[String]
12
- const :skip_codeowners_validation, T::Boolean
13
-
14
- sig { returns(Configuration) }
15
- def self.fetch
16
- config_hash = YAML.load_file('config/code_ownership.yml')
17
-
18
- new(
19
- owned_globs: config_hash.fetch('owned_globs', []),
20
- unowned_globs: config_hash.fetch('unowned_globs', []),
21
- js_package_paths: js_package_paths(config_hash),
22
- skip_codeowners_validation: config_hash.fetch('skip_codeowners_validation', false)
23
- )
24
- end
25
-
26
- sig { params(config_hash: T::Hash[T.untyped, T.untyped]).returns(T::Array[String]) }
27
- def self.js_package_paths(config_hash)
28
- specified_package_paths = config_hash['js_package_paths']
29
- if specified_package_paths.nil?
30
- DEFAULT_JS_PACKAGE_PATHS.dup
31
- else
32
- Array(specified_package_paths)
33
- end
34
- end
35
- end
36
- end
37
- end
@@ -1,50 +0,0 @@
1
- # frozen_string_literal: true
2
-
3
- # typed: strict
4
-
5
- module CodeOwnership
6
- module Private
7
- module OwnershipMappers
8
- module Interface
9
- extend T::Sig
10
- extend T::Helpers
11
-
12
- interface!
13
-
14
- #
15
- # This should be fast when run with ONE file
16
- #
17
- sig do
18
- abstract.params(file: String).
19
- returns(T.nilable(::CodeTeams::Team))
20
- end
21
- def map_file_to_owner(file)
22
- end
23
-
24
- #
25
- # This should be fast when run with MANY files
26
- #
27
- sig do
28
- abstract.params(files: T::Array[String]).
29
- returns(T::Hash[String, T.nilable(::CodeTeams::Team)])
30
- end
31
- def map_files_to_owners(files)
32
- end
33
-
34
- sig do
35
- abstract.returns(T::Hash[String, T.nilable(::CodeTeams::Team)])
36
- end
37
- def codeowners_lines_to_owners
38
- end
39
-
40
- sig { abstract.returns(String) }
41
- def description
42
- end
43
-
44
- sig { abstract.void }
45
- def bust_caches!
46
- end
47
- end
48
- end
49
- end
50
- end
@@ -1,18 +0,0 @@
1
- # typed: strict
2
-
3
- module CodeOwnership
4
- module Private
5
- module Validations
6
- module Interface
7
- extend T::Sig
8
- extend T::Helpers
9
-
10
- interface!
11
-
12
- sig { abstract.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
13
- def validation_errors(files:, autocorrect: true, stage_changes: true)
14
- end
15
- end
16
- end
17
- end
18
- end
@@ -1,30 +0,0 @@
1
- # typed: strict
2
-
3
- module CodeOwnership
4
- module Private
5
- module Validations
6
- class NoOverlappingGlobs
7
- extend T::Sig
8
- extend T::Helpers
9
- include Interface
10
-
11
- sig { override.params(files: T::Array[String], autocorrect: T::Boolean, stage_changes: T::Boolean).returns(T::Array[String]) }
12
- def validation_errors(files:, autocorrect: true, stage_changes: true)
13
- overlapping_globs = OwnershipMappers::TeamGlobs.new.find_overlapping_globs
14
-
15
- errors = T.let([], T::Array[String])
16
-
17
- if overlapping_globs.any?
18
- errors << <<~MSG
19
- `owned_globs` cannot overlap between teams. The following globs overlap:
20
-
21
- #{overlapping_globs.map { |overlap| "- #{overlap.description}"}.join("\n")}
22
- MSG
23
- end
24
-
25
- errors
26
- end
27
- end
28
- end
29
- end
30
- end