use_packwerk 0.50.0

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.
@@ -0,0 +1,446 @@
1
+ # typed: strict
2
+
3
+ require 'pathname'
4
+ require 'fileutils'
5
+ require 'colorized_string'
6
+ require 'sorbet-runtime'
7
+
8
+ require 'use_packwerk/private/file_move_operation'
9
+ require 'use_packwerk/private/pack_relationship_analyzer'
10
+
11
+ module UsePackwerk
12
+ module Private
13
+ extend T::Sig
14
+
15
+ sig { params(pack_name: String).returns(String) }
16
+ def self.clean_pack_name(pack_name)
17
+ # The reason we do this is a lot of terminals add an extra `/` when you tab-autocomplete.
18
+ # This results in the pack not being found, but when we write the package YML it writes to the same place,
19
+ # causing a behaviorally confusing diff.
20
+ # We ignore trailing slashes as an ergonomic feature to the user.
21
+ pack_name.gsub(/\/$/, '')
22
+ end
23
+
24
+ sig do
25
+ params(
26
+ file: String,
27
+ find: Pathname,
28
+ replace_with: Pathname,
29
+ ).void
30
+ end
31
+ def self.replace_in_file(file:, find:, replace_with:)
32
+ file = Pathname.new(file)
33
+ return if !file.exist?
34
+ count = 0
35
+ file.write(file.read.gsub(find.to_s) do
36
+ count += 1
37
+ replace_with.to_s
38
+ end)
39
+ Logging.print "Replaced #{count} occurrence(s) of #{find} in #{file.to_s}" if count > 0
40
+ end
41
+
42
+ sig do
43
+ params(
44
+ pack_name: String,
45
+ enforce_privacy: T::Boolean,
46
+ enforce_dependencies: T.nilable(T::Boolean)
47
+ ).void
48
+ end
49
+ def self.create_pack!(pack_name:, enforce_privacy:, enforce_dependencies:)
50
+ Logging.section('👋 Hi!') do
51
+ intro = <<~INTRO
52
+ You are creating a pack, which is great. Check out #{UsePackwerk.config.documentation_link} for more info!
53
+
54
+ Please bring any questions or issues you have in your development process to #ruby-modularity or #product-infrastructure.
55
+ We'd be happy to try to help through pairing, accepting feedback, changing our process, changing our tools, and more.
56
+ INTRO
57
+ Logging.print_bold_green(intro)
58
+ end
59
+
60
+ pack_name = Private.clean_pack_name(pack_name)
61
+
62
+ package = create_pack_if_not_exists!(pack_name: pack_name, enforce_privacy: enforce_privacy, enforce_dependencies: enforce_dependencies)
63
+ add_public_directory(package)
64
+ add_readme_todo(package)
65
+
66
+ Logging.section('Next steps') do
67
+ next_steps = <<~NEXT_STEPS
68
+ Your next steps might be:
69
+
70
+ 1) Move files into your pack with `bin/move_to_pack -n #{pack_name} -f path/to/file.rb`
71
+
72
+ 2) Run `bin/packwerk update-deprecations` to update the violations. Make sure to run `spring stop` if you've added new load paths (new top-level directories) in your pack.
73
+
74
+ 3) Update TODO lists for rubocop implemented protections. See #{UsePackwerk.config.documentation_link} for more info
75
+
76
+ 4) Expose public API in #{pack_name}/app/public. Try `bin/make_public -f #{pack_name}/path/to/file.rb`
77
+
78
+ 5) Update your readme at #{pack_name}/README.md
79
+ NEXT_STEPS
80
+
81
+ Logging.print_bold_green(next_steps)
82
+ end
83
+ end
84
+
85
+ sig do
86
+ params(
87
+ pack_name: String,
88
+ paths_relative_to_root: T::Array[String],
89
+ per_file_processors: T::Array[UsePackwerk::PerFileProcessorInterface]
90
+ ).void
91
+ end
92
+ def self.move_to_pack!(pack_name:, paths_relative_to_root:, per_file_processors: [])
93
+ pack_name = Private.clean_pack_name(pack_name)
94
+ package = ParsePackwerk.all.find { |package| package.name == pack_name }
95
+ if package.nil?
96
+ raise StandardError.new("Can not find package with name #{pack_name}. Make sure the argument is of the form `packs/my_pack/`")
97
+ end
98
+
99
+ Logging.section('👋 Hi!') do
100
+ intro = <<~INTRO
101
+ You are moving a file to a pack, which is great. Check out #{UsePackwerk.config.documentation_link} for more info!
102
+
103
+ Please bring any questions or issues you have in your development process to #ruby-modularity or #product-infrastructure.
104
+ We'd be happy to try to help through pairing, accepting feedback, changing our process, changing our tools, and more.
105
+ INTRO
106
+ Logging.print_bold_green(intro)
107
+ end
108
+
109
+ add_public_directory(package)
110
+ add_readme_todo(package)
111
+ package_location = package.directory
112
+
113
+ if paths_relative_to_root.any?
114
+ Logging.section('File Operations') do
115
+ file_paths = paths_relative_to_root.flat_map do |path|
116
+ origin_pathname = Pathname.new(path).cleanpath
117
+ # Note -- we used to `mv` over whole directories, rather than splatting out their contents and merging individual files.
118
+ # The main advantage to moving whole directories is that it's a bit faster and a bit less verbose
119
+ # However, this ended up being tricky and caused complexity to flow down later parts of the implementation.
120
+ # Notably:
121
+ # 1) The `mv` operation doesn't merge directories, so if the destination already has the same directory, then the mv operation
122
+ # will overwrite
123
+ # 2) We could get around this possibly with `cp_r` (https://ruby-doc.org/stdlib-1.9.3/libdoc/fileutils/rdoc/FileUtils.html#method-c-cp_r),
124
+ # but we'd also have to delete the origin destination. On top of this, we still need to splat things out later on so that we can do
125
+ # per file processor operations, and that has some complexity of its own. The simplest thing here would be to simply glob everything out.
126
+ #
127
+ # For now, we sacrifice some small level of speed and conciseness in favor of simpler implementation.
128
+ # Later, if we choose to go back to moving whole directories at a time, it should be a refactor and all tests should still pass
129
+ #
130
+ if origin_pathname.directory?
131
+ origin_pathname.glob('**/*.{rb,rake,erb}')
132
+ else
133
+ origin_pathname
134
+ end
135
+ end
136
+ file_move_operations = file_paths.map do |origin_pathname|
137
+ FileMoveOperation.new(
138
+ origin_pathname: origin_pathname,
139
+ destination_pathname: FileMoveOperation.destination_pathname_for_package_move(origin_pathname, package_location),
140
+ destination_pack: package,
141
+ )
142
+ end
143
+ file_move_operations.each do |file_move_operation|
144
+ Private.package_filepath(file_move_operation, per_file_processors)
145
+ Private.package_filepath_spec(file_move_operation, per_file_processors)
146
+ end
147
+ end
148
+ end
149
+
150
+ per_file_processors.each do |per_file_processor|
151
+ per_file_processor.print_final_message!
152
+ end
153
+
154
+ Logging.section('Next steps') do
155
+ next_steps = <<~NEXT_STEPS
156
+ Your next steps might be:
157
+
158
+ 1) Run `bin/packwerk update-deprecations` to update the violations. Make sure to run `spring stop` if you've added new load paths (new top-level directories) in your pack.
159
+
160
+ 2) Update TODO lists for rubocop implemented protections. See #{UsePackwerk.config.documentation_link} for more info
161
+
162
+ 3) Touch base with each team who owns files involved in this move
163
+
164
+ 4) Expose public API in #{pack_name}/app/public. Try `bin/make_public -f #{pack_name}/path/to/file.rb`
165
+
166
+ 5) Update your readme at #{pack_name}/README.md
167
+ NEXT_STEPS
168
+
169
+ Logging.print_bold_green(next_steps)
170
+ end
171
+ end
172
+
173
+ sig do
174
+ params(
175
+ paths_relative_to_root: T::Array[String],
176
+ per_file_processors: T::Array[UsePackwerk::PerFileProcessorInterface]
177
+ ).void
178
+ end
179
+ def self.make_public!(paths_relative_to_root:, per_file_processors:)
180
+ Logging.section('Making files public') do
181
+ intro = <<~INTRO
182
+ You are moving some files into public API. See #{UsePackwerk.config.documentation_link} for other utilities!
183
+ INTRO
184
+ Logging.print_bold_green(intro)
185
+ end
186
+
187
+ if paths_relative_to_root.any?
188
+ Logging.section('File Operations') do
189
+ file_paths = paths_relative_to_root.flat_map do |path|
190
+ origin_pathname = Pathname.new(path).cleanpath
191
+ if origin_pathname.directory?
192
+ origin_pathname.glob('**/*.rb').map(&:to_s)
193
+ else
194
+ path
195
+ end
196
+ end
197
+
198
+
199
+ file_move_operations = file_paths.map do |path|
200
+ parts = path.to_s.split('/')
201
+ first_part_of_path = T.must(parts[0])
202
+
203
+ if Pathname.new(first_part_of_path).dirname.join(ParsePackwerk::PACKAGE_YML_NAME).exist?
204
+ package_location = Pathname.new('.')
205
+ elsif PERMITTED_PACK_LOCATIONS.include?(first_part_of_path)
206
+ package_location = Pathname.new(first_part_of_path).join(T.must(parts[1]))
207
+ else
208
+ raise StandardError.new('Can only make files in the monolith or in existing packs public')
209
+ end
210
+
211
+ package = ParsePackwerk::Package.from(package_location.join(ParsePackwerk::PACKAGE_YML_NAME))
212
+
213
+ origin_pathname = Pathname.new(path).cleanpath
214
+
215
+ FileMoveOperation.new(
216
+ origin_pathname: origin_pathname,
217
+ destination_pathname: FileMoveOperation.destination_pathname_for_new_public_api(origin_pathname),
218
+ destination_pack: package,
219
+ )
220
+ end
221
+
222
+ file_move_operations.each do |file_move_operation|
223
+ Private.package_filepath(file_move_operation, per_file_processors)
224
+ Private.package_filepath_spec(file_move_operation, per_file_processors)
225
+ end
226
+ end
227
+ end
228
+
229
+ Logging.section('Next steps') do
230
+ next_steps = <<~NEXT_STEPS
231
+ Your next steps might be:
232
+
233
+ 1) Run `bin/packwerk update-deprecations` to update the violations. Make sure to run `spring stop` if you've added new load paths (new top-level directories) in your pack.
234
+
235
+ 2) Update TODO lists for rubocop implemented protections. See #{UsePackwerk.config.documentation_link} for more info
236
+
237
+ 3) Work to migrate clients of private API to your new public API
238
+
239
+ 4) Update your README at packs/your_package_name/README.md
240
+ NEXT_STEPS
241
+
242
+ Logging.print_bold_green(next_steps)
243
+ end
244
+ end
245
+
246
+ sig do
247
+ params(
248
+ pack_name: String,
249
+ dependency_name: String
250
+ ).void
251
+ end
252
+ def self.add_dependency!(pack_name:, dependency_name:)
253
+ Logging.section('Adding a dependency') do
254
+ intro = <<~INTRO
255
+ You are adding a dependency. See #{UsePackwerk.config.documentation_link} for other utilities!
256
+ INTRO
257
+ Logging.print_bold_green(intro)
258
+ end
259
+
260
+ all_packages = ParsePackwerk.all
261
+
262
+ pack_name = Private.clean_pack_name(pack_name)
263
+ package = all_packages.find { |package| package.name == pack_name }
264
+ if package.nil?
265
+ raise StandardError.new("Can not find package with name #{pack_name}. Make sure the argument is of the form `packs/my_pack/`")
266
+ end
267
+
268
+ dependency_name = Private.clean_pack_name(dependency_name)
269
+ package_dependency = all_packages.find { |package| package.name == dependency_name }
270
+ if package_dependency.nil?
271
+ raise StandardError.new("Can not find package with name #{dependency_name}. Make sure the argument is of the form `packs/my_pack/`")
272
+ end
273
+
274
+ new_package = ParsePackwerk::Package.new(
275
+ name: pack_name,
276
+ dependencies: (package.dependencies + [dependency_name]).uniq.sort,
277
+ enforce_privacy: package.enforce_privacy,
278
+ enforce_dependencies: package.enforce_dependencies,
279
+ metadata: package.metadata,
280
+ )
281
+ ParsePackwerk.write_package_yml!(new_package)
282
+
283
+ Logging.section('Next steps') do
284
+ next_steps = <<~NEXT_STEPS
285
+ Your next steps might be:
286
+
287
+ 1) Run `bin/packwerk validate` to ensure you haven't introduced a cyclic dependency
288
+
289
+ 2) Run `bin/packwerk update-deprecations` to update the violations.
290
+ NEXT_STEPS
291
+
292
+ Logging.print_bold_green(next_steps)
293
+ end
294
+ end
295
+
296
+ sig { params(file_move_operation: FileMoveOperation, per_file_processors: T::Array[UsePackwerk::PerFileProcessorInterface]).void }
297
+ def self.package_filepath(file_move_operation, per_file_processors)
298
+ per_file_processors.each do |per_file_processor|
299
+ if file_move_operation.origin_pathname.exist?
300
+ per_file_processor.before_move_file!(file_move_operation)
301
+ end
302
+ end
303
+
304
+ origin = file_move_operation.origin_pathname
305
+ destination = file_move_operation.destination_pathname
306
+ idempotent_mv(origin, destination)
307
+ end
308
+
309
+ sig { params(file_move_operation: FileMoveOperation, per_file_processors: T::Array[UsePackwerk::PerFileProcessorInterface]).void }
310
+ def self.package_filepath_spec(file_move_operation, per_file_processors)
311
+ package_filepath(file_move_operation.spec_file_move_operation, per_file_processors)
312
+ end
313
+
314
+ sig { params(origin: Pathname, destination: Pathname).void }
315
+ def self.idempotent_mv(origin, destination)
316
+ if origin.exist? && destination.exist?
317
+ Logging.print ColorizedString.new("[SKIP] Not moving #{origin}, #{destination} already exists").red
318
+ elsif origin.exist? && !destination.exist?
319
+ destination.dirname.mkpath
320
+
321
+ Logging.print "Moving file #{origin} to #{destination}"
322
+ # use git mv so that git knows that it was a move
323
+ FileUtils.mv(origin, destination)
324
+ elsif !origin.exist? && destination.exist?
325
+ Logging.print ColorizedString.new("[SKIP] Not moving #{origin.to_s}, does not exist, (#{destination.to_s} already exists)").red
326
+ else
327
+ Logging.print ColorizedString.new("[SKIP] Not moving #{origin.to_s}, does not exist").red
328
+ end
329
+ end
330
+
331
+ sig { params(package: ParsePackwerk::Package).void }
332
+ def self.add_public_directory(package)
333
+ public_directory = package.directory.join('app/public')
334
+
335
+ if public_directory.glob('**/**.rb').none?
336
+ FileUtils.mkdir_p(public_directory)
337
+ todo_md = <<~TODO
338
+ This directory holds your public API!
339
+
340
+ Any classes, constants, or modules that you want other packs to use and you intend to support should go in here.
341
+ Anything that is considered private should go in other folders.
342
+
343
+ If another pack uses classes, constants, or modules that are not in your public folder, it will be considered a "privacy violation" by packwerk.
344
+ You can prevent other packs from using private API by using package_protections.
345
+
346
+ Want to find how your private API is being used today?
347
+ Try running: `bin/list_top_privacy_violations -n #{package.name}`
348
+
349
+ Want to move something into this folder?
350
+ Try running: `bin/make_public -f #{package.name}/path/to/file.rb`
351
+
352
+ One more thing -- feel free to delete this file and replace it with a README.md describing your package in the main package directory.
353
+
354
+ See #{UsePackwerk.config.documentation_link} for more info!
355
+ TODO
356
+ public_directory.join('TODO.md').write(todo_md)
357
+ end
358
+ end
359
+
360
+ sig { params(package: ParsePackwerk::Package).void }
361
+ def self.add_readme_todo(package)
362
+ pack_directory = package.directory
363
+
364
+ if !pack_directory.join('README.md').exist?
365
+ readme_todo_md = <<~TODO
366
+ Welcome to `#{package.name}`!
367
+
368
+ If you're the author, please consider replacing this file with a README.md, which may contain:
369
+ - What your pack is and does
370
+ - How you expect people to use your pack
371
+ - Example usage of your pack's public API (which lives in `#{package.name}/app/public`)
372
+ - Limitations, risks, and important considerations of usage
373
+ - How to get in touch with eng and other stakeholders for questions or issues pertaining to this pack (note: it is recommended to add ownership in `#{package.name}/package.yml` under the `owner` metadata key)
374
+ - What SLAs/SLOs (service level agreements/objectives), if any, your package provides
375
+ - When in doubt, keep it simple
376
+ - Anything else you may want to include!
377
+
378
+ README.md files are under version control and should change as your public API changes.
379
+
380
+ See #{UsePackwerk.config.documentation_link} for more info!
381
+ TODO
382
+ pack_directory.join('README_TODO.md').write(readme_todo_md)
383
+ end
384
+ end
385
+
386
+ sig do
387
+ params(
388
+ pack_name: String,
389
+ enforce_privacy: T::Boolean,
390
+ enforce_dependencies: T.nilable(T::Boolean)
391
+ ).returns(ParsePackwerk::Package)
392
+ end
393
+ def self.create_pack_if_not_exists!(pack_name:, enforce_privacy:, enforce_dependencies:)
394
+ if PERMITTED_PACK_LOCATIONS.none? { |permitted_location| pack_name.start_with?(permitted_location) }
395
+ raise StandardError.new("UsePackwerk only supports packages in the the following directories: #{PERMITTED_PACK_LOCATIONS.inspect}. Please make sure to pass in the name of the pack including the full directory path, e.g. `packs/my_pack`.")
396
+ end
397
+
398
+ existing_package = ParsePackwerk.all.find { |package| package.name == pack_name }
399
+
400
+ package_location = Pathname.new(pack_name)
401
+
402
+ if existing_package.nil?
403
+ should_enforce_dependenceies = enforce_dependencies.nil? ? UsePackwerk.config.enforce_dependencies : enforce_dependencies
404
+
405
+ package = ParsePackwerk::Package.new(
406
+ enforce_dependencies: should_enforce_dependenceies,
407
+ enforce_privacy: enforce_privacy,
408
+ dependencies: [],
409
+ metadata: {
410
+ 'owner' => 'MyTeam'
411
+ },
412
+ name: pack_name,
413
+ )
414
+
415
+ ParsePackwerk.write_package_yml!(package)
416
+ PackageProtections.set_defaults!([package], verbose: false)
417
+ package = rewrite_package_with_original_packwerk_values(package)
418
+
419
+ current_contents = package.yml.read
420
+ new_contents = current_contents.gsub("MyTeam", "MyTeam # specify your team here, or delete this key if this package is not owned by one team")
421
+ package.yml.write(new_contents)
422
+ existing_package = package
423
+ end
424
+
425
+ existing_package
426
+ end
427
+
428
+ sig { params(original_package: ParsePackwerk::Package).returns(ParsePackwerk::Package) }
429
+ def self.rewrite_package_with_original_packwerk_values(original_package)
430
+ package_with_protection_defaults = T.must(ParsePackwerk.all.find { |package| package.name == original_package.name })
431
+ # PackageProtections also sets `enforce_privacy` and `enforce_dependency` to be true, so we set these back down to their original values
432
+ package = ParsePackwerk::Package.new(
433
+ enforce_dependencies: original_package.enforce_dependencies,
434
+ enforce_privacy: original_package.enforce_privacy,
435
+ dependencies: original_package.dependencies,
436
+ metadata: package_with_protection_defaults.metadata,
437
+ name: original_package.name,
438
+ )
439
+
440
+ ParsePackwerk.write_package_yml!(package)
441
+ package
442
+ end
443
+ end
444
+
445
+ private_constant :Private
446
+ end
@@ -0,0 +1,22 @@
1
+ # typed: strict
2
+
3
+ module UsePackwerk
4
+ class RubocopPostProcessor
5
+ include PerFileProcessorInterface
6
+ extend T::Sig
7
+
8
+ sig { override.params(file_move_operation: Private::FileMoveOperation).void }
9
+ def before_move_file!(file_move_operation)
10
+ relative_path_to_origin = file_move_operation.origin_pathname
11
+ relative_path_to_destination = file_move_operation.destination_pathname
12
+
13
+ rubocop_todo = Pathname.new('.rubocop_todo.yml')
14
+ return if !rubocop_todo.exist?
15
+ UsePackwerk.replace_in_file(
16
+ file: rubocop_todo.to_s,
17
+ find: relative_path_to_origin,
18
+ replace_with: relative_path_to_destination,
19
+ )
20
+ end
21
+ end
22
+ end
@@ -0,0 +1,149 @@
1
+ # typed: strict
2
+
3
+ # Ruby internal requires
4
+ require 'fileutils'
5
+
6
+ # External gem requires
7
+ require 'colorized_string'
8
+
9
+ # Internal gem requires
10
+ require 'parse_packwerk'
11
+ require 'code_teams'
12
+ require 'code_ownership'
13
+ require 'package_protections'
14
+
15
+ # Private implementation requires
16
+ require 'use_packwerk/private'
17
+ require 'use_packwerk/per_file_processor_interface'
18
+ require 'use_packwerk/rubocop_post_processor'
19
+ require 'use_packwerk/code_ownership_post_processor'
20
+ require 'use_packwerk/logging'
21
+ require 'use_packwerk/configuration'
22
+ require 'use_packwerk/cli'
23
+
24
+ module UsePackwerk
25
+ extend T::Sig
26
+
27
+ PERMITTED_PACK_LOCATIONS = T.let([
28
+ 'gems',
29
+ 'components',
30
+ 'packs',
31
+ ], T::Array[String])
32
+
33
+ sig do
34
+ params(
35
+ pack_name: String,
36
+ enforce_privacy: T::Boolean,
37
+ enforce_dependencies: T.nilable(T::Boolean)
38
+ ).void
39
+ end
40
+ def self.create_pack!(
41
+ pack_name:,
42
+ enforce_privacy: true,
43
+ enforce_dependencies: nil
44
+ )
45
+ Private.create_pack!(
46
+ pack_name: pack_name,
47
+ enforce_privacy: enforce_privacy,
48
+ enforce_dependencies: enforce_dependencies,
49
+ )
50
+ end
51
+
52
+ sig do
53
+ params(
54
+ pack_name: String,
55
+ paths_relative_to_root: T::Array[String],
56
+ per_file_processors: T::Array[PerFileProcessorInterface],
57
+ ).void
58
+ end
59
+ def self.move_to_pack!(
60
+ pack_name:,
61
+ paths_relative_to_root: [],
62
+ per_file_processors: []
63
+ )
64
+ Private.move_to_pack!(
65
+ pack_name: pack_name,
66
+ paths_relative_to_root: paths_relative_to_root,
67
+ per_file_processors: per_file_processors,
68
+ )
69
+ end
70
+
71
+ sig do
72
+ params(
73
+ paths_relative_to_root: T::Array[String],
74
+ per_file_processors: T::Array[PerFileProcessorInterface],
75
+ ).void
76
+ end
77
+ def self.make_public!(
78
+ paths_relative_to_root: [],
79
+ per_file_processors: []
80
+ )
81
+ Private.make_public!(
82
+ paths_relative_to_root: paths_relative_to_root,
83
+ per_file_processors: per_file_processors
84
+ )
85
+ end
86
+
87
+ sig do
88
+ params(
89
+ pack_name: String,
90
+ dependency_name: String
91
+ ).void
92
+ end
93
+ def self.add_dependency!(
94
+ pack_name:,
95
+ dependency_name:
96
+ )
97
+ Private.add_dependency!(
98
+ pack_name: pack_name,
99
+ dependency_name: dependency_name
100
+ )
101
+ end
102
+
103
+ sig do
104
+ params(
105
+ pack_name: T.nilable(String),
106
+ limit: Integer,
107
+ ).void
108
+ end
109
+ def self.list_top_privacy_violations(
110
+ pack_name:,
111
+ limit:
112
+ )
113
+ Private::PackRelationshipAnalyzer.list_top_privacy_violations(
114
+ pack_name,
115
+ limit
116
+ )
117
+ end
118
+
119
+ sig do
120
+ params(
121
+ pack_name: T.nilable(String),
122
+ limit: Integer
123
+ ).void
124
+ end
125
+ def self.list_top_dependency_violations(
126
+ pack_name:,
127
+ limit:
128
+ )
129
+ Private::PackRelationshipAnalyzer.list_top_dependency_violations(
130
+ pack_name,
131
+ limit
132
+ )
133
+ end
134
+
135
+ sig do
136
+ params(
137
+ file: String,
138
+ find: Pathname,
139
+ replace_with: Pathname,
140
+ ).void
141
+ end
142
+ def self.replace_in_file(file:, find:, replace_with:)
143
+ Private.replace_in_file(
144
+ file: file,
145
+ find: find,
146
+ replace_with: replace_with,
147
+ )
148
+ end
149
+ end