use_packwerk 0.50.0

Sign up to get free protection for your applications and to get access to all the features.
@@ -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