code_ownership 2.0.0.pre.1-x64-mingw-ucrt → 2.1.3-x64-mingw-ucrt

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3184eb314ac8d7a612bc93f7e2f10d3920773830601ee6c94792e4ce809b3c5b
4
- data.tar.gz: 9974b2ba6b5a755476f47efe93988c7b4bc69b875064c02c3d16461c110852ae
3
+ metadata.gz: 824cabc8c0760c992bffd8bc1410a220f92aecd86f95ab5653fb1cfb7b282ef1
4
+ data.tar.gz: 14d955eee3158c0edf56da3f8ae3da0a4f8620d7012b52d37696510ae7f0f56a
5
5
  SHA512:
6
- metadata.gz: aa4eeed10aae508939d1f001f7db0b5ae3c3c186232f896e525fee1caa940127fa97b9a0062ee84881912258133d369e470598c57c29e3586cc8ae20c4e301a6
7
- data.tar.gz: 8c8409aa9de19a3849ed2f507c741b4cf70ba1d7a4f6a67c1af675b29cd85c657c1e7a7b8a851a12477db3421db2d3678f8fd0e0ad531a75c29395f521781335
6
+ metadata.gz: deebbcf22becdb436b047f2551b3a12f3664f2410ea1dd9269391c252fb22ce25745245a13985d257b64294755f199747283d05b6abf125b953547e6fab8d99b
7
+ data.tar.gz: 3947a41c61ab114aa5f79e9a4178a85d1d02f18a65a64faf29ed3dc7971cfd3181f73e7962e3970808c1acfbf681f9ff6f6745df509361a8e370dbbc4f4886e4
data/README.md CHANGED
@@ -6,13 +6,14 @@ Check out [`lib/code_ownership.rb`](https://github.com/rubyatscale/code_ownershi
6
6
 
7
7
  Check out [`code_ownership_spec.rb`](https://github.com/rubyatscale/code_ownership/blob/main/spec/lib/code_ownership_spec.rb) to see examples of how code ownership is used.
8
8
 
9
- There is also a [companion VSCode Extension]([url](https://github.com/rubyatscale/code-ownership-vscode)) for this gem. Just search `Gusto.code-ownership-vscode` in the VSCode Extension Marketplace.
9
+ There is also a [companion VSCode Extension](https://github.com/rubyatscale/code-ownership-vscode) for this gem. Just search `Gusto.code-ownership-vscode` in the VSCode Extension Marketplace.
10
10
 
11
11
  ## Getting started
12
12
 
13
- To get started there's a few things you should do.
13
+ To get started there are a few things you should do.
14
+
15
+ 1. Create a `config/code_ownership.yml` file and declare where your files live. Here's a sample to start with:
14
16
 
15
- 1) Create a `config/code_ownership.yml` file and declare where your files live. Here's a sample to start with:
16
17
  ```yml
17
18
  owned_globs:
18
19
  - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}'
@@ -23,33 +24,42 @@ unowned_globs:
23
24
  - app/services/some_file2.rb
24
25
  - frontend/javascripts/**/__generated__/**/*
25
26
  ```
26
- 2) Declare some teams. Here's an example, that would live at `config/teams/operations.yml`:
27
+
28
+ 2. Declare some teams. Here's an example, that would live at `config/teams/operations.yml`:
29
+
27
30
  ```yml
28
31
  name: Operations
29
32
  github:
30
33
  team: '@my-org/operations-team'
31
34
  ```
32
- 3) Declare ownership. You can do this at a directory level or at a file level. All of the files within the `owned_globs` you declared in step 1 will need to have an owner assigned (or be opted out via `unowned_globs`). See the next section for more detail.
33
- 4) Run validations when you commit, and/or in CI. If you run validations in CI, ensure that if your `.github/CODEOWNERS` file gets changed, that gets pushed to the PR.
35
+
36
+ 3. Declare ownership. You can do this at a directory level or at a file level. All of the files within the `owned_globs` you declared in step 1 will need to have an owner assigned (or be opted out via `unowned_globs`). See the next section for more detail.
37
+ 4. Run validations when you commit, and/or in CI. If you run validations in CI, ensure that if your `.github/CODEOWNERS` file gets changed, that gets pushed to the PR.
34
38
 
35
39
  ## Usage: Declaring Ownership
36
40
 
37
41
  There are five ways to declare code ownership using this gem:
38
42
 
39
43
  ### Directory-Based Ownership
40
- Directory based ownership allows for all files in that directory and all its sub-directories to be owned by one team. To define this, add a `.codeowner` file inside that directory with the name of the team as the contents of that file.
44
+
45
+ Directory-based ownership allows for all files in that directory and all its sub-directories to be owned by one team. To define this, add a `.codeowner` file inside that directory with the name of the team as the contents of that file.
46
+
41
47
  ```
42
48
  Team
43
49
  ```
44
50
 
45
51
  ### File-Annotation Based Ownership
52
+
46
53
  File annotations are a last resort if there is no clear home for your code. File annotations go at the top of your file, and look like this:
54
+
47
55
  ```ruby
48
56
  # @team MyTeam
49
57
  ```
50
58
 
51
59
  ### Package-Based Ownership
52
- Package based ownership integrates [`packwerk`](https://github.com/Shopify/packwerk) and has ownership defined per package. To define that all files within a package are owned by one team, configure your `package.yml` like this:
60
+
61
+ Package-based ownership integrates [`packwerk`](https://github.com/Shopify/packwerk) and has ownership defined per package. To define that all files within a package are owned by one team, configure your `package.yml` like this:
62
+
53
63
  ```yml
54
64
  enforce_dependency: true
55
65
  enforce_privacy: true
@@ -58,16 +68,19 @@ metadata:
58
68
  ```
59
69
 
60
70
  You can also define `owner` as a top-level key, e.g.
71
+
61
72
  ```yml
62
73
  enforce_dependency: true
63
74
  enforce_privacy: true
64
75
  owner: Team
65
76
  ```
66
77
 
67
- To do this, add `code_ownership` to the `require` key of your `packwerk.yml`. See https://github.com/Shopify/packwerk/blob/main/USAGE.md#loading-extensions for more information.
78
+ To do this, add `code_ownership` to the `require` key of your `packwerk.yml`. See <https://github.com/Shopify/packwerk/blob/main/USAGE.md#loading-extensions> for more information.
68
79
 
69
80
  ### Glob-Based Ownership
81
+
70
82
  In your team's configured YML (see [`code_teams`](https://github.com/rubyatscale/code_teams)), you can set `owned_globs` to be a glob of files your team owns. For example, in `my_team.yml`:
83
+
71
84
  ```yml
72
85
  name: My Team
73
86
  owned_globs:
@@ -78,7 +91,8 @@ unowned_globs:
78
91
  ```
79
92
 
80
93
  ### Javascript Package Ownership
81
- Javascript package based ownership allows you to specify an ownership key in a `package.json`. To use this, configure your `package.json` like this:
94
+
95
+ JavaScript package-based ownership allows you to specify an ownership key in a `package.json`. To use this, configure your `package.json` like this:
82
96
 
83
97
  ```json
84
98
  {
@@ -91,22 +105,26 @@ Javascript package based ownership allows you to specify an ownership key in a `
91
105
  ```
92
106
 
93
107
  You can also tell `code_ownership` where to find JS packages in the configuration, like this:
108
+
94
109
  ```yml
95
110
  js_package_paths:
96
111
  - frontend/javascripts/packages/*
97
112
  - frontend/other_location_for_packages/*
98
113
  ```
99
114
 
100
- This defaults `**/`, which makes it look for `package.json` files across your application.
115
+ This defaults to `**/`, which makes it look for `package.json` files across your application.
101
116
 
102
117
  > [!NOTE]
103
- > Javscript package ownership does not respect `unowned_globs`. If you wish to disable usage of this feature you can set `js_package_paths` to an empty list.
118
+ > JavaScript package ownership does not respect `unowned_globs`. If you wish to disable usage of this feature you can set `js_package_paths` to an empty list.
119
+
104
120
  ```yml
105
121
  js_package_paths: []
106
122
  ```
107
123
 
108
124
  ## Usage: Reading CodeOwnership
125
+
109
126
  ### `for_file`
127
+
110
128
  `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.
111
129
 
112
130
  ```ruby
@@ -118,6 +136,7 @@ Contributor note: If you are making updates to this method or the methods gettin
118
136
  See `code_ownership_spec.rb` for examples.
119
137
 
120
138
  ### `for_backtrace`
139
+
121
140
  `CodeOwnership.for_backtrace` can be given a backtrace and will either return `nil`, or a `CodeTeams::Team`.
122
141
 
123
142
  ```ruby
@@ -141,19 +160,22 @@ Under the hood, this finds the file where the class is defined and returns the o
141
160
  See `code_ownership_spec.rb` for an example.
142
161
 
143
162
  ### `for_team`
163
+
144
164
  `CodeOwnership.for_team` can be used to generate an ownership report for a team.
165
+
145
166
  ```ruby
146
167
  CodeOwnership.for_team('My Team')
147
168
  ```
148
169
 
149
170
  You can shovel this into a markdown file for easy viewing using the CLI:
171
+
150
172
  ```
151
- bin/codeownership for_team 'My Team' > tmp/ownership_report.md
173
+ codeownership for_team 'My Team' > tmp/ownership_report.md
152
174
  ```
153
175
 
154
176
  ## Usage: Generating a `CODEOWNERS` file
155
177
 
156
- A `CODEOWNERS` file defines who owns specific files or paths in a repository. When you run `bin/codeownership validate`, a `.github/CODEOWNERS` file will automatically be generated and updated.
178
+ A `CODEOWNERS` file defines who owns specific files or paths in a repository. When you run `codeownership validate`, a `.github/CODEOWNERS` file will automatically be generated and updated.
157
179
 
158
180
  If `codeowners_path` is set in `code_ownership.yml` codeowners will use that path to generate the `CODEOWNERS` file. For example, `codeowners_path: docs` will generate `docs/CODEOWNERS`.
159
181
 
@@ -161,14 +183,15 @@ If `codeowners_path` is set in `code_ownership.yml` codeowners will use that pat
161
183
 
162
184
  CodeOwnership comes with a validation function to ensure the following things are true:
163
185
 
164
- 1) Only one mechanism is defining file ownership. That is -- you can't have a file annotation on a file owned via package-based or glob-based ownership. This helps make ownership behavior more clear by avoiding concerns about precedence.
165
- 2) All teams referenced as an owner for any file or package is a valid team (i.e. it's in the list of `CodeTeams.all`).
166
- 3) All files have ownership. You can specify in `unowned_globs` to represent a TODO list of files to add ownership to.
167
- 3) The `.github/CODEOWNERS` file is up to date. This is automatically corrected and staged unless specified otherwise with `bin/codeownership validate --skip-autocorrect --skip-stage`. You can turn this validation off by setting `skip_codeowners_validation: true` in `config/code_ownership.yml`.
186
+ 1. Only one mechanism is defining file ownership. That is -- you can't have a file annotation on a file owned via package-based or glob-based ownership. This helps make ownership behavior more clear by avoiding concerns about precedence.
187
+ 2. All teams referenced as an owner for any file or package are valid teams (i.e. they're in the list of `CodeTeams.all`).
188
+ 3. All files have ownership. You can specify in `unowned_globs` to represent a TODO list of files to add ownership to.
189
+ 4. The `.github/CODEOWNERS` file is up to date. This is automatically corrected and staged unless specified otherwise with `bin/codeownership validate --skip-autocorrect --skip-stage`. You can turn this validation off by setting `skip_codeowners_validation: true` in `config/code_ownership.yml`.
168
190
 
169
191
  CodeOwnership also allows you to specify which globs and file extensions should be considered ownable.
170
192
 
171
193
  Here is an example `config/code_ownership.yml`.
194
+
172
195
  ```yml
173
196
  owned_globs:
174
197
  - '{app,components,config,frontend,lib,packs,spec}/**/*.{rb,rake,js,jsx,ts,tsx}'
@@ -178,24 +201,37 @@ unowned_globs:
178
201
  - app/services/some_file2.rb
179
202
  - frontend/javascripts/**/__generated__/**/*
180
203
  ```
204
+
181
205
  You can call the validation function with the Ruby API
206
+
182
207
  ```ruby
183
208
  CodeOwnership.validate!
184
209
  ```
210
+
185
211
  or the CLI
186
- ```
187
- bin/codeownership validate
212
+
213
+ ```bash
214
+ # Validate all files
215
+ codeownership validate
216
+
217
+ # Validate specific files
218
+ codeownership validate path/to/file1.rb path/to/file2.rb
219
+
220
+ # Validate only staged files
221
+ codeownership validate --diff
188
222
  ```
189
223
 
190
224
  ## Development
191
225
 
192
- Please add to `CHANGELOG.md` and this `README.md` when you make make changes.
226
+ Please add to `CHANGELOG.md` and this `README.md` when you make changes.
193
227
 
194
228
  ## Running specs
229
+
195
230
  ```sh
196
231
  bundle install
197
232
  bundle exec rake
198
233
  ```
199
234
 
200
235
  ## Creating a new release
236
+
201
237
  Simply [create a new release](https://github.com/rubyatscale/code_ownership/releases/new) with github. The release tag must match the gem version
@@ -1,4 +1,5 @@
1
1
  # typed: true
2
+ # frozen_string_literal: true
2
3
 
3
4
  require 'optparse'
4
5
  require 'pathname'
@@ -37,7 +38,7 @@ module CodeOwnership
37
38
  options = {}
38
39
 
39
40
  parser = OptionParser.new do |opts|
40
- opts.banner = "Usage: #{EXECUTABLE} validate [options]"
41
+ opts.banner = "Usage: #{EXECUTABLE} validate [options] [files...]"
41
42
 
42
43
  opts.on('--skip-autocorrect', 'Skip automatically correcting any errors, such as the .github/CODEOWNERS file') do
43
44
  options[:skip_autocorrect] = true
@@ -59,11 +60,22 @@ module CodeOwnership
59
60
  args = parser.order!(argv)
60
61
  parser.parse!(args)
61
62
 
62
- files = if options[:diff]
63
+ # Collect any remaining arguments as file paths
64
+ specified_files = argv.reject { |arg| arg.start_with?('--') }
65
+
66
+ files = if !specified_files.empty?
67
+ # Files explicitly provided on command line
68
+ if options[:diff]
69
+ warn 'Warning: Ignoring --diff flag because explicit files were provided'
70
+ end
71
+ specified_files.select { |file| File.exist?(file) }
72
+ elsif options[:diff]
73
+ # Staged files from git
63
74
  ENV.fetch('CODEOWNERS_GIT_STAGED_FILES') { `git diff --staged --name-only` }.split("\n").select do |file|
64
75
  File.exist?(file)
65
76
  end
66
77
  else
78
+ # No files specified, validate all
67
79
  nil
68
80
  end
69
81
 
@@ -95,6 +107,10 @@ module CodeOwnership
95
107
  options[:json] = true
96
108
  end
97
109
 
110
+ opts.on('--verbose', 'Output verbose information') do
111
+ options[:verbose] = true
112
+ end
113
+
98
114
  opts.on('--help', 'Shows this prompt') do
99
115
  puts opts
100
116
  exit
@@ -107,24 +123,7 @@ module CodeOwnership
107
123
  raise "Please pass in one file. Use `#{EXECUTABLE} for_file --help` for more info"
108
124
  end
109
125
 
110
- team = CodeOwnership.for_file(files.first)
111
-
112
- team_name = team&.name || 'Unowned'
113
- team_yml = team&.config_yml || 'Unowned'
114
-
115
- if options[:json]
116
- json = {
117
- team_name: team_name,
118
- team_yml: team_yml
119
- }
120
-
121
- puts json.to_json
122
- else
123
- puts <<~MSG
124
- Team: #{team_name}
125
- Team YML: #{team_yml}
126
- MSG
127
- end
126
+ puts CodeOwnership::Private::ForFileOutputBuilder.build(file_path: files.first, json: !!options[:json], verbose: !!options[:verbose])
128
127
  end
129
128
 
130
129
  def self.for_team(argv)
@@ -1,29 +1,41 @@
1
1
  # frozen_string_literal: true
2
-
3
2
  # typed: strict
4
3
 
5
4
  module CodeOwnership
6
5
  module Private
7
6
  module FilePathFinder
8
- module_function
9
-
10
7
  extend T::Sig
11
- extend T::Helpers
8
+
9
+ sig { returns(String) }
10
+ def self.pwd_prefix
11
+ @pwd_prefix ||= T.let("#{Dir.pwd}/", T.nilable(String))
12
+ end
13
+
14
+ sig { returns(Pathname) }
15
+ def self.pwd
16
+ @pwd ||= T.let(Pathname.pwd, T.nilable(Pathname))
17
+ end
12
18
 
13
19
  # Returns a string version of the relative path to a Rails constant,
14
20
  # or nil if it can't find anything
15
- sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(String)) }
16
- def path_from_klass(klass)
21
+ sig { params(klass: T.nilable(T.any(T::Class[T.anything], T::Module[T.anything]))).returns(T.nilable(String)) }
22
+ def self.path_from_klass(klass)
17
23
  if klass
18
24
  path = Object.const_source_location(klass.to_s)&.first
19
- (path && Pathname.new(path).relative_path_from(Pathname.pwd).to_s) || nil
25
+ return nil unless path
26
+
27
+ if path.start_with?(pwd_prefix)
28
+ path.delete_prefix(pwd_prefix)
29
+ else
30
+ Pathname.new(path).relative_path_from(pwd).to_s
31
+ end
20
32
  end
21
33
  rescue NameError
22
34
  nil
23
35
  end
24
36
 
25
37
  sig { params(backtrace: T.nilable(T::Array[String])).returns(T::Enumerable[String]) }
26
- def from_backtrace(backtrace)
38
+ def self.from_backtrace(backtrace)
27
39
  return [] unless backtrace
28
40
 
29
41
  # The pattern for a backtrace hasn't changed in forever and is considered
@@ -34,7 +46,7 @@ module CodeOwnership
34
46
  # ./app/controllers/some_controller.rb:43:in `block (3 levels) in create'
35
47
  #
36
48
  backtrace_line = if RUBY_VERSION >= '3.4.0'
37
- %r{\A(#{Pathname.pwd}/|\./)?
49
+ %r{\A(#{pwd}/|\./)?
38
50
  (?<file>.+) # Matches 'app/controllers/some_controller.rb'
39
51
  :
40
52
  (?<line>\d+) # Matches '43'
@@ -42,7 +54,7 @@ module CodeOwnership
42
54
  '(?<function>.*)' # Matches "`block (3 levels) in create'"
43
55
  \z}x
44
56
  else
45
- %r{\A(#{Pathname.pwd}/|\./)?
57
+ %r{\A(#{pwd}/|\./)?
46
58
  (?<file>.+) # Matches 'app/controllers/some_controller.rb'
47
59
  :
48
60
  (?<line>\d+) # Matches '43'
@@ -1,37 +1,33 @@
1
1
  # frozen_string_literal: true
2
-
3
2
  # typed: strict
4
3
 
5
4
  module CodeOwnership
6
5
  module Private
7
6
  module FilePathTeamCache
8
- module_function
9
-
10
7
  extend T::Sig
11
- extend T::Helpers
12
8
 
13
9
  sig { params(file_path: String).returns(T.nilable(CodeTeams::Team)) }
14
- def get(file_path)
10
+ def self.get(file_path)
15
11
  cache[file_path]
16
12
  end
17
13
 
18
14
  sig { params(file_path: String, team: T.nilable(CodeTeams::Team)).void }
19
- def set(file_path, team)
15
+ def self.set(file_path, team)
20
16
  cache[file_path] = team
21
17
  end
22
18
 
23
19
  sig { params(file_path: String).returns(T::Boolean) }
24
- def cached?(file_path)
20
+ def self.cached?(file_path)
25
21
  cache.key?(file_path)
26
22
  end
27
23
 
28
24
  sig { void }
29
- def bust_cache!
25
+ def self.bust_cache!
30
26
  @cache = nil
31
27
  end
32
28
 
33
29
  sig { returns(T::Hash[String, T.nilable(CodeTeams::Team)]) }
34
- def cache
30
+ def self.cache
35
31
  @cache ||= T.let(@cache,
36
32
  T.nilable(T::Hash[String, T.nilable(CodeTeams::Team)]))
37
33
  @cache ||= {}
@@ -0,0 +1,83 @@
1
+ # frozen_string_literal: true
2
+ # typed: strict
3
+
4
+ module CodeOwnership
5
+ module Private
6
+ class ForFileOutputBuilder
7
+ extend T::Sig
8
+
9
+ private_class_method :new
10
+
11
+ sig { params(file_path: String, json: T::Boolean, verbose: T::Boolean).void }
12
+ def initialize(file_path:, json:, verbose:)
13
+ @file_path = file_path
14
+ @json = json
15
+ @verbose = verbose
16
+ end
17
+
18
+ sig { params(file_path: String, json: T::Boolean, verbose: T::Boolean).returns(String) }
19
+ def self.build(file_path:, json:, verbose:)
20
+ new(file_path: file_path, json: json, verbose: verbose).build
21
+ end
22
+
23
+ UNOWNED_OUTPUT = T.let(
24
+ {
25
+ team_name: 'Unowned',
26
+ team_yml: 'Unowned'
27
+ },
28
+ T::Hash[Symbol, T.untyped]
29
+ )
30
+
31
+ sig { returns(String) }
32
+ def build
33
+ result_hash = @verbose ? build_verbose : build_terse
34
+
35
+ return result_hash.to_json if @json
36
+
37
+ build_message_for(result_hash)
38
+ end
39
+
40
+ private
41
+
42
+ sig { returns(T::Hash[Symbol, T.untyped]) }
43
+ def build_verbose
44
+ result = CodeOwnership.for_file_verbose(@file_path)
45
+ return UNOWNED_OUTPUT if result.nil?
46
+
47
+ {
48
+ team_name: result[:team_name],
49
+ team_yml: result[:team_config_yml],
50
+ description: result[:reasons]
51
+ }
52
+ end
53
+
54
+ sig { returns(T::Hash[Symbol, T.untyped]) }
55
+ def build_terse
56
+ team = CodeOwnership.for_file(@file_path, from_codeowners: false, allow_raise: true)
57
+
58
+ if team.nil?
59
+ UNOWNED_OUTPUT
60
+ else
61
+ {
62
+ team_name: team.name,
63
+ team_yml: team.config_yml
64
+ }
65
+ end
66
+ end
67
+
68
+ sig { params(result_hash: T::Hash[Symbol, T.untyped]).returns(String) }
69
+ def build_message_for(result_hash)
70
+ messages = ["Team: #{result_hash[:team_name]}", "Team YML: #{result_hash[:team_yml]}"]
71
+ description_list = T.let(Array(result_hash[:description]), T::Array[String])
72
+ messages << build_description_message(description_list) unless description_list.empty?
73
+ messages.last << "\n"
74
+ messages.join("\n")
75
+ end
76
+
77
+ sig { params(reasons: T::Array[String]).returns(String) }
78
+ def build_description_message(reasons)
79
+ "Description:\n- #{reasons.join("\n-")}"
80
+ end
81
+ end
82
+ end
83
+ end
@@ -1,37 +1,57 @@
1
1
  # frozen_string_literal: true
2
-
3
2
  # typed: strict
4
3
 
5
4
  module CodeOwnership
6
5
  module Private
7
6
  module TeamFinder
8
- module_function
9
-
10
7
  extend T::Sig
11
- extend T::Helpers
12
8
 
13
- requires_ancestor { Kernel }
14
-
15
- sig { params(file_path: String).returns(T.nilable(CodeTeams::Team)) }
16
- def for_file(file_path)
9
+ sig { params(file_path: String, allow_raise: T::Boolean).returns(T.nilable(CodeTeams::Team)) }
10
+ def self.for_file(file_path, allow_raise: false)
17
11
  return nil if file_path.start_with?('./')
18
12
 
19
13
  return FilePathTeamCache.get(file_path) if FilePathTeamCache.cached?(file_path)
20
14
 
21
15
  result = T.let(RustCodeOwners.for_file(file_path), T.nilable(T::Hash[Symbol, String]))
22
- return if result.nil?
23
16
 
24
- if result[:team_name].nil?
17
+ if result.nil? || result[:team_name].nil?
25
18
  FilePathTeamCache.set(file_path, nil)
26
19
  else
27
- FilePathTeamCache.set(file_path, T.let(find_team!(T.must(result[:team_name])), T.nilable(CodeTeams::Team)))
20
+ FilePathTeamCache.set(file_path, T.let(find_team!(T.must(result[:team_name]), allow_raise: allow_raise), T.nilable(CodeTeams::Team)))
28
21
  end
29
22
 
30
23
  FilePathTeamCache.get(file_path)
31
24
  end
32
25
 
33
- sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(::CodeTeams::Team)) }
34
- def for_class(klass)
26
+ sig { params(files: T::Array[String], allow_raise: T::Boolean).returns(T::Hash[String, T.nilable(CodeTeams::Team)]) }
27
+ def self.teams_for_files(files, allow_raise: false)
28
+ result = {}
29
+
30
+ # Collect cached results and identify non-cached files
31
+ not_cached_files = []
32
+ files.each do |file_path|
33
+ if FilePathTeamCache.cached?(file_path)
34
+ result[file_path] = FilePathTeamCache.get(file_path)
35
+ else
36
+ not_cached_files << file_path
37
+ end
38
+ end
39
+
40
+ return result if not_cached_files.empty?
41
+
42
+ # Process non-cached files
43
+ ::RustCodeOwners.teams_for_files(not_cached_files).each do |path_team|
44
+ file_path, team = path_team
45
+ found_team = team ? find_team!(team[:team_name], allow_raise: allow_raise) : nil
46
+ FilePathTeamCache.set(file_path, found_team)
47
+ result[file_path] = found_team
48
+ end
49
+
50
+ result
51
+ end
52
+
53
+ sig { params(klass: T.nilable(T.any(T::Class[T.anything], T::Module[T.anything]))).returns(T.nilable(::CodeTeams::Team)) }
54
+ def self.for_class(klass)
35
55
  file_path = FilePathFinder.path_from_klass(klass)
36
56
  return nil if file_path.nil?
37
57
 
@@ -39,20 +59,20 @@ module CodeOwnership
39
59
  end
40
60
 
41
61
  sig { params(package: Packs::Pack).returns(T.nilable(::CodeTeams::Team)) }
42
- def for_package(package)
62
+ def self.for_package(package)
43
63
  owner_name = package.raw_hash['owner'] || package.metadata['owner']
44
64
  return nil if owner_name.nil?
45
65
 
46
- find_team!(owner_name)
66
+ find_team!(owner_name, allow_raise: true)
47
67
  end
48
68
 
49
69
  sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable(::CodeTeams::Team)) }
50
- def for_backtrace(backtrace, excluded_teams: [])
70
+ def self.for_backtrace(backtrace, excluded_teams: [])
51
71
  first_owned_file_for_backtrace(backtrace, excluded_teams: excluded_teams)&.first
52
72
  end
53
73
 
54
74
  sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable([::CodeTeams::Team, String])) }
55
- def first_owned_file_for_backtrace(backtrace, excluded_teams: [])
75
+ def self.first_owned_file_for_backtrace(backtrace, excluded_teams: [])
56
76
  FilePathFinder.from_backtrace(backtrace).each do |file|
57
77
  team = for_file(file)
58
78
  if team && !excluded_teams.include?(team)
@@ -63,10 +83,14 @@ module CodeOwnership
63
83
  nil
64
84
  end
65
85
 
66
- sig { params(team_name: String).returns(CodeTeams::Team) }
67
- def find_team!(team_name)
68
- CodeTeams.find(team_name) ||
86
+ sig { params(team_name: String, allow_raise: T::Boolean).returns(T.nilable(CodeTeams::Team)) }
87
+ def self.find_team!(team_name, allow_raise: false)
88
+ team = CodeTeams.find(team_name)
89
+ if team.nil? && allow_raise
69
90
  raise(StandardError, "Could not find team with name: `#{team_name}`. Make sure the team is one of `#{CodeTeams.all.map(&:name).sort}`")
91
+ end
92
+
93
+ team
70
94
  end
71
95
 
72
96
  private_class_method(:find_team!)
@@ -1,5 +1,6 @@
1
+ # typed: strict
1
2
  # frozen_string_literal: true
2
3
 
3
4
  module CodeOwnership
4
- VERSION = '2.0.0-1'
5
+ VERSION = '2.1.3'
5
6
  end
@@ -1,8 +1,6 @@
1
1
  # frozen_string_literal: true
2
-
3
2
  # typed: strict
4
3
 
5
- require 'set'
6
4
  require 'code_teams'
7
5
  require 'sorbet-runtime'
8
6
  require 'json'
@@ -11,6 +9,7 @@ require 'code_ownership/version'
11
9
  require 'code_ownership/private/file_path_finder'
12
10
  require 'code_ownership/private/file_path_team_cache'
13
11
  require 'code_ownership/private/team_finder'
12
+ require 'code_ownership/private/for_file_output_builder'
14
13
  require 'code_ownership/cli'
15
14
 
16
15
  begin
@@ -25,34 +24,196 @@ if defined?(Packwerk)
25
24
  end
26
25
 
27
26
  module CodeOwnership
28
- module_function
29
-
30
27
  extend T::Sig
31
- extend T::Helpers
32
28
 
33
- requires_ancestor { Kernel }
34
29
  GlobsToOwningTeamMap = T.type_alias { T::Hash[String, CodeTeams::Team] }
35
30
 
31
+ # Returns the version of the code_ownership gem and the codeowners-rs gem.
36
32
  sig { returns(T::Array[String]) }
37
- def version
33
+ def self.version
38
34
  ["code_ownership version: #{VERSION}",
39
35
  "codeowners-rs version: #{::RustCodeOwners.version}"]
40
36
  end
41
37
 
42
- sig { params(file: String).returns(T.nilable(CodeTeams::Team)) }
43
- def for_file(file)
44
- Private::TeamFinder.for_file(file)
38
+ # Returns the owning team for a given file path.
39
+ #
40
+ # @param file [String] The path to the file to find ownership for. Can be relative or absolute.
41
+ # @param from_codeowners [Boolean] (default: true) When true, uses CODEOWNERS file to determine ownership.
42
+ # When false, uses alternative team finding strategies (e.g., package ownership).
43
+ # from_codeowners true is faster because it simply matches the provided file to the generate CODEOWNERS file. This is a safe option when you can trust the CODEOWNERS file to be up to date.
44
+ # @param allow_raise [Boolean] (default: false) When true, raises an exception if ownership cannot be determined.
45
+ # When false, returns nil for files without ownership.
46
+ #
47
+ # @return [CodeTeams::Team, nil] The team that owns the file, or nil if no owner is found
48
+ # (unless allow_raise is true, in which case an exception is raised).
49
+ #
50
+ # @example Find owner for a file using CODEOWNERS
51
+ # team = CodeOwnership.for_file('app/models/user.rb')
52
+ # # => #<CodeTeams::Team:0x... @name="platform">
53
+ #
54
+ # @example Find owner without using CODEOWNERS
55
+ # team = CodeOwnership.for_file('app/models/user.rb', from_codeowners: false)
56
+ # # => #<CodeTeams::Team:0x... @name="platform">
57
+ #
58
+ # @example Raise if no owner is found
59
+ # team = CodeOwnership.for_file('unknown_file.rb', allow_raise: true)
60
+ # # => raises exception if no owner found
61
+ #
62
+ sig { params(file: String, from_codeowners: T::Boolean, allow_raise: T::Boolean).returns(T.nilable(CodeTeams::Team)) }
63
+ def self.for_file(file, from_codeowners: true, allow_raise: false)
64
+ if from_codeowners
65
+ teams_for_files_from_codeowners([file], allow_raise: allow_raise).values.first
66
+ else
67
+ Private::TeamFinder.for_file(file, allow_raise: allow_raise)
68
+ end
69
+ end
70
+
71
+ # Returns the owning teams for multiple file paths using the CODEOWNERS file.
72
+ #
73
+ # This method efficiently determines ownership for multiple files in a single operation
74
+ # by leveraging the generated CODEOWNERS file. It's more performant than calling
75
+ # `for_file` multiple times when you need to check ownership for many files.
76
+ #
77
+ # @param files [Array<String>] An array of file paths to find ownership for.
78
+ # Paths can be relative to the project root or absolute.
79
+ # @param allow_raise [Boolean] (default: false) When true, raises an exception if a team
80
+ # name in CODEOWNERS cannot be resolved to an actual team.
81
+ # When false, returns nil for files with unresolvable teams.
82
+ #
83
+ # @return [T::Hash[String, T.nilable(CodeTeams::Team)]] A hash mapping each file path to its
84
+ # owning team. Files without ownership
85
+ # or with unresolvable teams will map to nil.
86
+ #
87
+ # @example Get owners for multiple files
88
+ # files = ['app/models/user.rb', 'app/controllers/users_controller.rb', 'config/routes.rb']
89
+ # owners = CodeOwnership.teams_for_files_from_codeowners(files)
90
+ # # => {
91
+ # # 'app/models/user.rb' => #<CodeTeams::Team:0x... @name="platform">,
92
+ # # 'app/controllers/users_controller.rb' => #<CodeTeams::Team:0x... @name="platform">,
93
+ # # 'config/routes.rb' => #<CodeTeams::Team:0x... @name="infrastructure">
94
+ # # }
95
+ #
96
+ # @example Handle files without owners
97
+ # files = ['owned_file.rb', 'unowned_file.txt']
98
+ # owners = CodeOwnership.teams_for_files_from_codeowners(files)
99
+ # # => {
100
+ # # 'owned_file.rb' => #<CodeTeams::Team:0x... @name="backend">,
101
+ # # 'unowned_file.txt' => nil
102
+ # # }
103
+ #
104
+ # @note This method uses caching internally for performance. The cache is populated
105
+ # as files are processed and reused for subsequent lookups.
106
+ #
107
+ # @note This method relies on the CODEOWNERS file being up-to-date. Run
108
+ # `CodeOwnership.validate!` to ensure the CODEOWNERS file is current.
109
+ #
110
+ # @see #for_file for single file ownership lookup
111
+ # @see #validate! for ensuring CODEOWNERS file is up-to-date
112
+ #
113
+ sig { params(files: T::Array[String], allow_raise: T::Boolean).returns(T::Hash[String, T.nilable(CodeTeams::Team)]) }
114
+ def self.teams_for_files_from_codeowners(files, allow_raise: false)
115
+ Private::TeamFinder.teams_for_files(files, allow_raise: allow_raise)
116
+ end
117
+
118
+ # Returns detailed ownership information for a given file path.
119
+ #
120
+ # This method provides verbose ownership details including the team name,
121
+ # team configuration file path, and the reasons/sources for ownership assignment.
122
+ # It's particularly useful for debugging ownership assignments and understanding
123
+ # why a file is owned by a specific team.
124
+ #
125
+ # @param file [String] The path to the file to find ownership for. Can be relative or absolute.
126
+ #
127
+ # @return [T::Hash[Symbol, String], nil] A hash containing detailed ownership information,
128
+ # or nil if no owner is found.
129
+ #
130
+ # The returned hash contains the following keys when an owner is found:
131
+ # - :team_name [String] - The name of the owning team
132
+ # - :team_config_yml [String] - Path to the team's configuration YAML file
133
+ # - :reasons [Array<String>] - List of reasons/sources explaining why this team owns the file
134
+ # (e.g., "CODEOWNERS pattern: /app/models/**", "Package ownership")
135
+ #
136
+ # @example Get verbose ownership details
137
+ # details = CodeOwnership.for_file_verbose('app/models/user.rb')
138
+ # # => {
139
+ # # team_name: "platform",
140
+ # # team_config_yml: "config/teams/platform.yml",
141
+ # # reasons: ["Matched pattern '/app/models/**' in CODEOWNERS"]
142
+ # # }
143
+ #
144
+ # @example Handle unowned files
145
+ # details = CodeOwnership.for_file_verbose('unowned_file.txt')
146
+ # # => nil
147
+ #
148
+ # @note This method is primarily used by the CLI tool when the --verbose flag is provided,
149
+ # allowing users to understand the ownership assignment logic.
150
+ #
151
+ # @note Unlike `for_file`, this method always uses the CODEOWNERS file and other ownership
152
+ # sources to determine ownership, providing complete context about the ownership decision.
153
+ #
154
+ # @see #for_file for a simpler ownership lookup that returns just the team
155
+ # @see CLI#for_file for the command-line interface that uses this method
156
+ #
157
+ sig { params(file: String).returns(T.nilable(T::Hash[Symbol, String])) }
158
+ def self.for_file_verbose(file)
159
+ ::RustCodeOwners.for_file(file)
45
160
  end
46
161
 
47
162
  sig { params(team: T.any(CodeTeams::Team, String)).returns(T::Array[String]) }
48
- def for_team(team)
163
+ def self.for_team(team)
49
164
  team = T.must(CodeTeams.find(team)) if team.is_a?(String)
50
165
  ::RustCodeOwners.for_team(team.name)
51
166
  end
52
167
 
53
- class InvalidCodeOwnershipConfigurationError < StandardError
54
- end
55
-
168
+ # Validates code ownership configuration and optionally corrects issues.
169
+ #
170
+ # This method performs comprehensive validation of the code ownership setup, ensuring:
171
+ # 1. Only one ownership mechanism is defined per file (no conflicts between annotations, packages, or globs)
172
+ # 2. All referenced teams are valid (exist in CodeTeams configuration)
173
+ # 3. All files have ownership (unless explicitly listed in unowned_globs)
174
+ # 4. The .github/CODEOWNERS file is up-to-date and properly formatted
175
+ #
176
+ # When autocorrect is enabled, the method will automatically:
177
+ # - Generate or update the CODEOWNERS file based on current ownership rules
178
+ # - Fix any formatting issues in the CODEOWNERS file
179
+ # - Stage the corrected CODEOWNERS file (unless stage_changes is false)
180
+ #
181
+ # @param autocorrect [Boolean] Whether to automatically fix correctable issues (default: true)
182
+ # When true, regenerates and updates the CODEOWNERS file
183
+ # When false, only validates without making changes
184
+ #
185
+ # @param stage_changes [Boolean] Whether to stage the CODEOWNERS file after autocorrection (default: true)
186
+ # Only applies when autocorrect is true
187
+ # When false, changes are written but not staged with git
188
+ #
189
+ # @param files [Array<String>, nil] Ignored. This is a legacy parameter that is no longer used.
190
+ #
191
+ # @return [void]
192
+ #
193
+ # @raise [RuntimeError] Raises an error if validation fails with details about:
194
+ # - Files with conflicting ownership definitions
195
+ # - References to non-existent teams
196
+ # - Files without ownership (not in unowned_globs)
197
+ # - CODEOWNERS file inconsistencies
198
+ #
199
+ # @example Basic validation with autocorrection
200
+ # CodeOwnership.validate!
201
+ # # Validates all files and auto-corrects/stages CODEOWNERS if needed
202
+ #
203
+ # @example Validation without making changes
204
+ # CodeOwnership.validate!(autocorrect: false)
205
+ # # Only checks for issues without updating CODEOWNERS
206
+ #
207
+ # @example Validate and fix but don't stage changes
208
+ # CodeOwnership.validate!(autocorrect: true, stage_changes: false)
209
+ # # Fixes CODEOWNERS but doesn't stage it with git
210
+ #
211
+ # @note This method is called by the CLI command: bin/codeownership validate
212
+ # @note The validation can be disabled for CODEOWNERS by setting skip_codeowners_validation: true in config/code_ownership.yml
213
+ #
214
+ # @see CLI.validate! for the command-line interface
215
+ # @see https://docs.github.com/en/repositories/managing-your-repositorys-settings-and-features/customizing-your-repository/about-code-owners for CODEOWNERS format
216
+ #
56
217
  sig do
57
218
  params(
58
219
  autocorrect: T::Boolean,
@@ -60,39 +221,90 @@ module CodeOwnership
60
221
  files: T.nilable(T::Array[String])
61
222
  ).void
62
223
  end
63
- def validate!(
224
+ def self.validate!(
64
225
  autocorrect: true,
65
226
  stage_changes: true,
66
227
  files: nil
67
228
  )
68
229
  if autocorrect
69
- ::RustCodeOwners.generate_and_validate(!stage_changes)
230
+ ::RustCodeOwners.generate_and_validate(files, !stage_changes)
70
231
  else
71
- ::RustCodeOwners.validate
232
+ ::RustCodeOwners.validate(files)
233
+ end
234
+ end
235
+
236
+ # Removes the file annotation (e.g., "# @team TeamName") from a file.
237
+ #
238
+ # This method removes the ownership annotation from the first line of a file,
239
+ # which is typically used to declare team ownership at the file level.
240
+ # The annotation can be in the form of:
241
+ # - Ruby comments: # @team TeamName
242
+ # - JavaScript/TypeScript comments: // @team TeamName
243
+ # - YAML comments: -# @team TeamName
244
+ #
245
+ # If the file does not have an annotation or the annotation doesn't match a valid team,
246
+ # this method does nothing.
247
+ #
248
+ # @param filename [String] The path to the file from which to remove the annotation.
249
+ # Can be relative or absolute.
250
+ #
251
+ # @return [void]
252
+ #
253
+ # @example Remove annotation from a Ruby file
254
+ # # Before: File contains "# @team Platform\nclass User; end"
255
+ # CodeOwnership.remove_file_annotation!('app/models/user.rb')
256
+ # # After: File contains "class User; end"
257
+ #
258
+ # @example Remove annotation from a JavaScript file
259
+ # # Before: File contains "// @team Frontend\nexport default function() {}"
260
+ # CodeOwnership.remove_file_annotation!('app/javascript/component.js')
261
+ # # After: File contains "export default function() {}"
262
+ #
263
+ # @note This method modifies the file in place.
264
+ # @note Leading newlines after the annotation are also removed to maintain clean formatting.
265
+ #
266
+ sig { params(filename: String).void }
267
+ def self.remove_file_annotation!(filename)
268
+ filepath = Pathname.new(filename)
269
+
270
+ begin
271
+ content = filepath.read
272
+ rescue Errno::EISDIR, Errno::ENOENT
273
+ # Ignore files that fail to read (directories, missing files, etc.)
274
+ return
72
275
  end
276
+
277
+ # Remove the team annotation and any trailing newlines after it
278
+ team_pattern = %r{\A(?:#|//|-#) @team .*\n+}
279
+ new_content = content.sub(team_pattern, '')
280
+
281
+ filepath.write(new_content) if new_content != content
282
+ rescue ArgumentError => e
283
+ # Handle invalid byte sequences gracefully
284
+ raise unless e.message.include?('invalid byte sequence')
73
285
  end
74
286
 
75
287
  # Given a backtrace from either `Exception#backtrace` or `caller`, find the
76
288
  # first line that corresponds to a file with assigned ownership
77
289
  sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable(::CodeTeams::Team)) }
78
- def for_backtrace(backtrace, excluded_teams: [])
290
+ def self.for_backtrace(backtrace, excluded_teams: [])
79
291
  Private::TeamFinder.for_backtrace(backtrace, excluded_teams: excluded_teams)
80
292
  end
81
293
 
82
294
  # Given a backtrace from either `Exception#backtrace` or `caller`, find the
83
295
  # first owned file in it, useful for figuring out which file is being blamed.
84
296
  sig { params(backtrace: T.nilable(T::Array[String]), excluded_teams: T::Array[::CodeTeams::Team]).returns(T.nilable([::CodeTeams::Team, String])) }
85
- def first_owned_file_for_backtrace(backtrace, excluded_teams: [])
297
+ def self.first_owned_file_for_backtrace(backtrace, excluded_teams: [])
86
298
  Private::TeamFinder.first_owned_file_for_backtrace(backtrace, excluded_teams: excluded_teams)
87
299
  end
88
300
 
89
- sig { params(klass: T.nilable(T.any(T::Class[T.anything], Module))).returns(T.nilable(::CodeTeams::Team)) }
90
- def for_class(klass)
301
+ sig { params(klass: T.nilable(T.any(T::Class[T.anything], T::Module[T.anything]))).returns(T.nilable(::CodeTeams::Team)) }
302
+ def self.for_class(klass)
91
303
  Private::TeamFinder.for_class(klass)
92
304
  end
93
305
 
94
306
  sig { params(package: Packs::Pack).returns(T.nilable(::CodeTeams::Team)) }
95
- def for_package(package)
307
+ def self.for_package(package)
96
308
  Private::TeamFinder.for_package(package)
97
309
  end
98
310
 
@@ -103,5 +315,7 @@ module CodeOwnership
103
315
  sig { void }
104
316
  def self.bust_caches!
105
317
  Private::FilePathTeamCache.bust_cache!
318
+ Private::FilePathFinder.instance_variable_set(:@pwd, nil)
319
+ Private::FilePathFinder.instance_variable_set(:@pwd_prefix, nil)
106
320
  end
107
321
  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: 2.0.0.pre.1
4
+ version: 2.1.3
5
5
  platform: x64-mingw-ucrt
6
6
  authors:
7
7
  - Gusto Engineers
8
8
  autorequire:
9
9
  bindir: bin
10
10
  cert_chain: []
11
- date: 2025-08-14 00:00:00.000000000 Z
11
+ date: 2026-06-10 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: code_teams
@@ -44,14 +44,14 @@ dependencies:
44
44
  requirements:
45
45
  - - ">="
46
46
  - !ruby/object:Gem::Version
47
- version: 0.5.11249
47
+ version: 0.6.12763
48
48
  type: :runtime
49
49
  prerelease: false
50
50
  version_requirements: !ruby/object:Gem::Requirement
51
51
  requirements:
52
52
  - - ">="
53
53
  - !ruby/object:Gem::Version
54
- version: 0.5.11249
54
+ version: 0.6.12763
55
55
  - !ruby/object:Gem::Dependency
56
56
  name: debug
57
57
  requirement: !ruby/object:Gem::Requirement
@@ -176,11 +176,13 @@ files:
176
176
  - README.md
177
177
  - bin/codeownership
178
178
  - lib/code_ownership.rb
179
- - lib/code_ownership/3.2/code_ownership.so
179
+ - lib/code_ownership/3.3/code_ownership.so
180
180
  - lib/code_ownership/3.4/code_ownership.so
181
+ - lib/code_ownership/4.0/code_ownership.so
181
182
  - lib/code_ownership/cli.rb
182
183
  - lib/code_ownership/private/file_path_finder.rb
183
184
  - lib/code_ownership/private/file_path_team_cache.rb
185
+ - lib/code_ownership/private/for_file_output_builder.rb
184
186
  - lib/code_ownership/private/permit_pack_owner_top_level_key.rb
185
187
  - lib/code_ownership/private/team_finder.rb
186
188
  - lib/code_ownership/version.rb
@@ -201,10 +203,10 @@ required_ruby_version: !ruby/object:Gem::Requirement
201
203
  requirements:
202
204
  - - ">="
203
205
  - !ruby/object:Gem::Version
204
- version: '3.2'
206
+ version: '3.3'
205
207
  - - "<"
206
208
  - !ruby/object:Gem::Version
207
- version: 3.5.dev
209
+ version: 4.1.dev
208
210
  required_rubygems_version: !ruby/object:Gem::Requirement
209
211
  requirements:
210
212
  - - ">="