kettle-dev 1.0.9 → 1.0.10

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.
Files changed (54) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/.envrc +4 -3
  4. data/.github/workflows/coverage.yml +3 -3
  5. data/.junie/guidelines.md +4 -3
  6. data/.simplecov +5 -1
  7. data/Appraisals +3 -0
  8. data/CHANGELOG.md +22 -1
  9. data/CONTRIBUTING.md +6 -0
  10. data/README.md +18 -5
  11. data/Rakefile +7 -11
  12. data/exe/kettle-commit-msg +9 -143
  13. data/exe/kettle-readme-backers +7 -353
  14. data/exe/kettle-release +8 -702
  15. data/lib/kettle/dev/ci_helpers.rb +1 -0
  16. data/lib/kettle/dev/commit_msg.rb +39 -0
  17. data/lib/kettle/dev/exit_adapter.rb +36 -0
  18. data/lib/kettle/dev/git_adapter.rb +120 -0
  19. data/lib/kettle/dev/git_commit_footer.rb +130 -0
  20. data/lib/kettle/dev/rakelib/appraisal.rake +8 -9
  21. data/lib/kettle/dev/rakelib/bench.rake +2 -7
  22. data/lib/kettle/dev/rakelib/bundle_audit.rake +2 -0
  23. data/lib/kettle/dev/rakelib/ci.rake +4 -396
  24. data/lib/kettle/dev/rakelib/install.rake +1 -295
  25. data/lib/kettle/dev/rakelib/reek.rake +2 -0
  26. data/lib/kettle/dev/rakelib/rubocop_gradual.rake +2 -0
  27. data/lib/kettle/dev/rakelib/spec_test.rake +2 -0
  28. data/lib/kettle/dev/rakelib/template.rake +3 -465
  29. data/lib/kettle/dev/readme_backers.rb +340 -0
  30. data/lib/kettle/dev/release_cli.rb +672 -0
  31. data/lib/kettle/dev/tasks/ci_task.rb +334 -0
  32. data/lib/kettle/dev/tasks/install_task.rb +298 -0
  33. data/lib/kettle/dev/tasks/template_task.rb +491 -0
  34. data/lib/kettle/dev/template_helpers.rb +4 -4
  35. data/lib/kettle/dev/version.rb +1 -1
  36. data/lib/kettle/dev.rb +30 -1
  37. data/lib/kettle-dev.rb +2 -3
  38. data/sig/kettle/dev/ci_helpers.rbs +8 -17
  39. data/sig/kettle/dev/commit_msg.rbs +8 -0
  40. data/sig/kettle/dev/exit_adapter.rbs +8 -0
  41. data/sig/kettle/dev/git_adapter.rbs +15 -0
  42. data/sig/kettle/dev/git_commit_footer.rbs +16 -0
  43. data/sig/kettle/dev/readme_backers.rbs +20 -0
  44. data/sig/kettle/dev/release_cli.rbs +8 -0
  45. data/sig/kettle/dev/tasks/ci_task.rbs +9 -0
  46. data/sig/kettle/dev/tasks/install_task.rbs +10 -0
  47. data/sig/kettle/dev/tasks/template_task.rbs +10 -0
  48. data/sig/kettle/dev/tasks.rbs +0 -0
  49. data/sig/kettle/dev/version.rbs +0 -0
  50. data/sig/kettle/emoji_regex.rbs +5 -0
  51. data/sig/kettle-dev.rbs +0 -0
  52. data.tar.gz.sig +0 -0
  53. metadata +55 -5
  54. metadata.gz.sig +0 -0
@@ -1,472 +1,10 @@
1
+ # frozen_string_literal: true
2
+
1
3
  namespace :kettle do
2
4
  namespace :dev do
3
- # New template task split from kettle:dev:install
4
- # Copies and replaces files pulled from this gem checkout into the host project.
5
- # This task can be run independently of `kettle:dev:install`.
6
5
  desc "Template kettle-dev files into the current project"
7
6
  task :template do
8
- require "fileutils"
9
- require "kettle/dev/template_helpers"
10
-
11
- helpers = Kettle::Dev::TemplateHelpers
12
-
13
- project_root = helpers.project_root
14
- gem_checkout_root = helpers.gem_checkout_root
15
-
16
- # Ensure git working tree is clean before making changes (when run standalone)
17
- helpers.ensure_clean_git!(root: project_root, task_label: "kettle:dev:template")
18
-
19
- meta = helpers.gemspec_metadata(project_root)
20
- gem_name = meta[:gem_name]
21
- min_ruby = meta[:min_ruby]
22
- gh_org = meta[:gh_org]
23
- entrypoint_require = meta[:entrypoint_require]
24
- namespace = meta[:namespace]
25
- namespace_shield = meta[:namespace_shield]
26
- gem_shield = meta[:gem_shield]
27
-
28
- # 1) .devcontainer directory
29
- helpers.copy_dir_with_prompt(File.join(gem_checkout_root, ".devcontainer"), File.join(project_root, ".devcontainer"))
30
-
31
- # 2) .github/**/*.yml with FUNDING.yml customizations
32
- source_github_dir = File.join(gem_checkout_root, ".github")
33
- if Dir.exist?(source_github_dir)
34
- Dir.glob(File.join(source_github_dir, "**", "*.yml")).each do |src|
35
- src = helpers.prefer_example(src)
36
- rel = src.sub(/^#{Regexp.escape(gem_checkout_root)}\/?/, "")
37
- dest = File.join(project_root, rel)
38
- if File.basename(src).sub(/\.example\z/, "") == "FUNDING.yml"
39
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
40
- c = content.dup
41
- c = c.gsub(/^open_collective:\s+.*$/i) { |line| gh_org ? "open_collective: #{gh_org}" : line }
42
- if gem_name && !gem_name.empty?
43
- c = c.gsub(/^tidelift:\s+.*$/i, "tidelift: rubygems/#{gem_name}")
44
- end
45
- # Also apply common replacements for org/gem/namespace/shields
46
- helpers.apply_common_replacements(
47
- c,
48
- gh_org: gh_org,
49
- gem_name: gem_name,
50
- namespace: namespace,
51
- namespace_shield: namespace_shield,
52
- gem_shield: gem_shield,
53
- )
54
- end
55
- else
56
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
57
- helpers.apply_common_replacements(
58
- content,
59
- gh_org: gh_org,
60
- gem_name: gem_name,
61
- namespace: namespace,
62
- namespace_shield: namespace_shield,
63
- gem_shield: gem_shield,
64
- )
65
- end
66
- end
67
- end
68
- end
69
-
70
- # 3) .qlty/qlty.toml
71
- helpers.copy_file_with_prompt(
72
- helpers.prefer_example(File.join(gem_checkout_root, ".qlty/qlty.toml")),
73
- File.join(project_root, ".qlty/qlty.toml"),
74
- allow_create: true,
75
- allow_replace: true,
76
- )
77
-
78
- # 4) gemfiles/modular/*.gemfile (from gem's gemfiles/modular)
79
- [%w[coverage.gemfile], %w[documentation.gemfile], %w[style.gemfile]].each do |base|
80
- src = helpers.prefer_example(File.join(gem_checkout_root, "gemfiles/modular", base[0]))
81
- dest = File.join(project_root, "gemfiles/modular", base[0])
82
- if File.basename(src).sub(/\.example\z/, "") == "style.gemfile"
83
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
84
- # Adjust rubocop-lts constraint based on min_ruby
85
- version_map = [
86
- [Gem::Version.new("1.8"), "~> 0.0"],
87
- [Gem::Version.new("1.9"), "~> 2.0"],
88
- [Gem::Version.new("2.0"), "~> 4.0"],
89
- [Gem::Version.new("2.1"), "~> 6.0"],
90
- [Gem::Version.new("2.2"), "~> 8.0"],
91
- [Gem::Version.new("2.3"), "~> 10.0"],
92
- [Gem::Version.new("2.4"), "~> 12.0"],
93
- [Gem::Version.new("2.5"), "~> 14.0"],
94
- [Gem::Version.new("2.6"), "~> 16.0"],
95
- [Gem::Version.new("2.7"), "~> 18.0"],
96
- [Gem::Version.new("3.0"), "~> 20.0"],
97
- [Gem::Version.new("3.1"), "~> 22.0"],
98
- [Gem::Version.new("3.2"), "~> 24.0"],
99
- [Gem::Version.new("3.3"), "~> 26.0"],
100
- [Gem::Version.new("3.4"), "~> 28.0"],
101
- ]
102
- new_constraint = nil
103
- begin
104
- if min_ruby && !min_ruby.empty?
105
- v = Gem::Version.new(min_ruby.split(".")[0, 2].join("."))
106
- version_map.reverse_each do |min, req|
107
- if v >= min
108
- new_constraint = req
109
- break
110
- end
111
- end
112
- end
113
- rescue StandardError
114
- # leave nil
115
- end
116
- if new_constraint
117
- content.gsub(/^gem\s+"rubocop-lts",\s*"[^"]+".*$/) do |line|
118
- # Do not preserve whatever tail was there before
119
- %(gem "rubocop-lts", "#{new_constraint}")
120
- end
121
- else
122
- content
123
- end
124
- end
125
- else
126
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
127
- end
128
- end
129
-
130
- # 5) spec/spec_helper.rb (no create)
131
- dest_spec_helper = File.join(project_root, "spec/spec_helper.rb")
132
- if File.file?(dest_spec_helper)
133
- old = File.read(dest_spec_helper)
134
- if old.include?('require "kettle/dev"') || old.include?("require 'kettle/dev'")
135
- replacement = %(require "#{entrypoint_require}")
136
- new_content = old.gsub(/require\s+["']kettle\/dev["']/, replacement)
137
- if new_content != old
138
- if helpers.ask("Replace require \"kettle/dev\" in spec/spec_helper.rb with #{replacement}?", true)
139
- helpers.write_file(dest_spec_helper, new_content)
140
- puts "Updated require in spec/spec_helper.rb"
141
- else
142
- puts "Skipped modifying spec/spec_helper.rb"
143
- end
144
- end
145
- end
146
- end
147
-
148
- # 6) .env.local special case: never overwrite project .env.local; copy template as .env.local.example
149
- begin
150
- envlocal_src = helpers.prefer_example(File.join(gem_checkout_root, ".env.local"))
151
- envlocal_dest = File.join(project_root, ".env.local.example")
152
- if File.exist?(envlocal_src)
153
- helpers.copy_file_with_prompt(envlocal_src, envlocal_dest, allow_create: true, allow_replace: true)
154
- end
155
- rescue StandardError => e
156
- puts "WARNING: Skipped .env.local example copy due to #{e.class}: #{e.message}"
157
- end
158
-
159
- # 7) Root and other files
160
- files_to_copy = %w[
161
- .envrc
162
- .gitignore
163
- .gitlab-ci.yml
164
- .rspec
165
- .rubocop.yml
166
- .simplecov
167
- .tool-versions
168
- .yard_gfm_support.rb
169
- .yardopts
170
- .opencollective.yml
171
- Appraisal.root.gemfile
172
- Appraisals
173
- CHANGELOG.md
174
- CITATION.cff
175
- CODE_OF_CONDUCT.md
176
- CONTRIBUTING.md
177
- Gemfile
178
- Rakefile
179
- README.md
180
- RUBOCOP.md
181
- SECURITY.md
182
- .junie/guidelines.md
183
- .junie/guidelines-rbs.md
184
- ]
185
-
186
- files_to_copy.each do |rel|
187
- src = helpers.prefer_example(File.join(gem_checkout_root, rel))
188
- dest = File.join(project_root, rel)
189
- next unless File.exist?(src)
190
- if File.basename(rel) == "README.md"
191
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
192
- # 1) Do token replacements on the template content (org/gem/namespace/shields)
193
- c = helpers.apply_common_replacements(
194
- content,
195
- gh_org: gh_org,
196
- gem_name: gem_name,
197
- namespace: namespace,
198
- namespace_shield: namespace_shield,
199
- gem_shield: gem_shield,
200
- )
201
-
202
- # 2) Merge specific sections from destination README, if present
203
- begin
204
- dest_existing = File.exist?(dest) ? File.read(dest) : nil
205
-
206
- # Helper to parse markdown sections at any heading level (#, ##, ###, ...)
207
- parse_sections = lambda do |md|
208
- sections = []
209
- return sections unless md
210
- lines = md.split("\n", -1) # keep trailing empty lines
211
- indices = []
212
- lines.each_with_index do |ln, i|
213
- indices << i if ln =~ /^#+\s+.+/
214
- end
215
- indices << lines.length
216
- indices.each_cons(2) do |start_i, nxt|
217
- heading = lines[start_i]
218
- body_lines = lines[(start_i + 1)...nxt] || []
219
- title = heading.sub(/^#+\s+/, "")
220
- # Normalize by removing leading emoji/non-alnum and extra spaces
221
- base = title.sub(/\A[^\p{Alnum}]+/u, "").strip.downcase
222
- sections << {start: start_i, stop: nxt - 1, heading: heading, body: body_lines.join("\n"), base: base}
223
- end
224
- {lines: lines, sections: sections}
225
- end
226
-
227
- # Parse src (c) and dest
228
- src_parsed = parse_sections.call(c)
229
- dest_parsed = parse_sections.call(dest_existing)
230
-
231
- # Build lookup for destination sections by base title
232
- dest_lookup = {}
233
- if dest_parsed && dest_parsed[:sections]
234
- dest_parsed[:sections].each do |s|
235
- dest_lookup[s[:base]] = s[:body]
236
- end
237
- end
238
-
239
- # Build targets to merge: existing curated list plus any NOTE sections at any level
240
- note_bases = []
241
- if src_parsed && src_parsed[:sections]
242
- note_bases = src_parsed[:sections]
243
- .select { |s| s[:heading] =~ /^#+\s+note:.*/i }
244
- .map { |s| s[:base] }
245
- end
246
- targets = ["synopsis", "configuration", "basic usage"] + note_bases
247
-
248
- # Replace matching sections in src
249
- if src_parsed && src_parsed[:sections] && !src_parsed[:sections].empty?
250
- lines = src_parsed[:lines].dup
251
- # Iterate over src sections; when base is in targets, rewrite its body
252
- src_parsed[:sections].reverse_each do |sec|
253
- next unless targets.include?(sec[:base])
254
- new_body = dest_lookup.fetch(sec[:base], "\n\n")
255
- new_block = [sec[:heading], new_body].join("\n")
256
- # Replace the range from start+0 to stop with new_block lines
257
- range_start = sec[:start]
258
- range_end = sec[:stop]
259
- # Remove old range
260
- lines.slice!(range_start..range_end)
261
- # Insert new block (split preserves potential empty tail)
262
- insert_lines = new_block.split("\n", -1)
263
- lines.insert(range_start, *insert_lines)
264
- end
265
- c = lines.join("\n")
266
- end
267
-
268
- # 3) Preserve first H1 emojis from destination README, if any
269
- begin
270
- require "kettle/emoji_regex"
271
- emoji_re = Kettle::EmojiRegex::REGEX
272
-
273
- dest_emojis = nil
274
- if dest_existing
275
- first_h1_dest = dest_existing.lines.find { |ln| ln =~ /^#\s+/ }
276
- if first_h1_dest
277
- after = first_h1_dest.sub(/^#\s+/, "")
278
- emojis = +""
279
- while after =~ /\A#{emoji_re.source}/u
280
- # Capture the entire grapheme cluster for the emoji (handles VS16/ZWJ sequences)
281
- cluster = after[/\A\X/u]
282
- emojis << cluster
283
- after = after[cluster.length..-1].to_s
284
- end
285
- dest_emojis = emojis unless emojis.empty?
286
- end
287
- end
288
-
289
- if dest_emojis && !dest_emojis.empty?
290
- lines_new = c.split("\n", -1)
291
- idx = lines_new.index { |ln| ln =~ /^#\s+/ }
292
- if idx
293
- rest = lines_new[idx].sub(/^#\s+/, "")
294
- # Remove any leading emojis from the H1 by peeling full grapheme clusters
295
- rest_wo_emoji = begin
296
- tmp = rest.dup
297
- while tmp =~ /\A#{emoji_re.source}/u
298
- cluster = tmp[/\A\X/u]
299
- tmp = tmp[cluster.length..-1].to_s
300
- end
301
- tmp.sub(/\A\s+/, "")
302
- end
303
- lines_new[idx] = ["#", dest_emojis, rest_wo_emoji].join(" ").gsub(/\s+/, " ").sub(/^#\s+/, "# ")
304
- c = lines_new.join("\n")
305
- end
306
- end
307
- rescue StandardError
308
- # ignore emoji preservation errors
309
- end
310
- rescue StandardError
311
- # Best effort; if anything fails, keep c as-is
312
- end
313
-
314
- c
315
- end
316
- elsif ["CHANGELOG.md", "CITATION.cff", "CONTRIBUTING.md", ".opencollective.yml", ".junie/guidelines.md"].include?(rel)
317
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true) do |content|
318
- helpers.apply_common_replacements(
319
- content,
320
- gh_org: gh_org,
321
- gem_name: gem_name,
322
- namespace: namespace,
323
- namespace_shield: namespace_shield,
324
- gem_shield: gem_shield,
325
- )
326
- end
327
- else
328
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
329
- end
330
- end
331
-
332
- # 7b) certs/pboling.pem
333
- begin
334
- cert_src = File.join(gem_checkout_root, "certs", "pboling.pem")
335
- cert_dest = File.join(project_root, "certs", "pboling.pem")
336
- if File.exist?(cert_src)
337
- helpers.copy_file_with_prompt(cert_src, cert_dest, allow_create: true, allow_replace: true)
338
- end
339
- rescue StandardError => e
340
- puts "WARNING: Skipped copying certs/pboling.pem due to #{e.class}: #{e.message}"
341
- end
342
-
343
- # After creating or replacing .envrc or .env.local.example, require review and exit unless allowed
344
- begin
345
- envrc_path = File.join(project_root, ".envrc")
346
- envlocal_example_path = File.join(project_root, ".env.local.example")
347
- changed_env_files = []
348
- changed_env_files << envrc_path if helpers.modified_by_template?(envrc_path)
349
- changed_env_files << envlocal_example_path if helpers.modified_by_template?(envlocal_example_path)
350
- if !changed_env_files.empty?
351
- if ENV.fetch("allowed", "").to_s =~ /\A(1|true|y|yes)\z/i
352
- puts "Detected updates to #{changed_env_files.map { |p| File.basename(p) }.join(" and ")}. Proceeding because allowed=true."
353
- else
354
- puts
355
- puts "IMPORTANT: The following environment-related files were created/updated:"
356
- changed_env_files.each { |p| puts " - #{p}" }
357
- puts
358
- puts "Please review these files. If .envrc changed, run:"
359
- puts " direnv allow"
360
- puts
361
- puts "After that, re-run to resume:"
362
- puts " bundle exec rake kettle:dev:template allowed=true"
363
- puts " # or to run the full install afterwards:"
364
- puts " bundle exec rake kettle:dev:install allowed=true"
365
- abort("Aborting: review of environment files required before continuing.")
366
- end
367
- end
368
- rescue StandardError => e
369
- puts "WARNING: Could not determine env file changes: #{e.class}: #{e.message}"
370
- end
371
-
372
- # Handle .git-hooks files
373
- # - Two template files (.txt) are offered with a location prompt (local/global/skip).
374
- # - Two hook scripts are always copied to both local and global locations by default;
375
- # if they already exist, ask before overwriting.
376
- source_hooks_dir = File.join(gem_checkout_root, ".git-hooks")
377
- if Dir.exist?(source_hooks_dir)
378
- goalie_src = File.join(source_hooks_dir, "commit-subjects-goalie.txt")
379
- footer_src = File.join(source_hooks_dir, "footer-template.erb.txt")
380
- hook_ruby_src = File.join(source_hooks_dir, "commit-msg")
381
- hook_sh_src = File.join(source_hooks_dir, "prepare-commit-msg")
382
-
383
- # First: templates (.txt) — keep the existing single question which applies only to these
384
- if File.file?(goalie_src) && File.file?(footer_src)
385
- puts
386
- puts "Git hooks templates found:"
387
- puts " - #{goalie_src}"
388
- puts " - #{footer_src}"
389
- puts
390
- puts "About these files:"
391
- puts "- commit-subjects-goalie.txt:"
392
- puts " Lists commit subject prefixes to look for; if a commit subject starts with any listed prefix,"
393
- puts " kettle-commit-msg will append a footer to the commit message (when GIT_HOOK_FOOTER_APPEND=true)."
394
- puts " Defaults include release prep (🔖 Prepare release v) and checksum commits (🔒️ Checksums for v)."
395
- puts "- footer-template.erb.txt:"
396
- puts " ERB template rendered to produce the footer. You can customize its contents and variables."
397
- puts
398
- puts "Where would you like to install these two templates?"
399
- puts " [l] Local to this project (#{File.join(project_root, ".git-hooks")})"
400
- puts " [g] Global for this user (#{File.join(ENV["HOME"], ".git-hooks")})"
401
- puts " [s] Skip copying"
402
- print "Choose (l/g/s) [l]: "
403
- choice = $stdin.gets&.strip
404
- choice = "l" if choice.nil? || choice.empty?
405
- dest_dir = case choice.downcase
406
- when "g", "global" then File.join(ENV["HOME"], ".git-hooks")
407
- when "s", "skip" then nil
408
- else File.join(project_root, ".git-hooks")
409
- end
410
-
411
- if dest_dir
412
- FileUtils.mkdir_p(dest_dir)
413
- [[goalie_src, "commit-subjects-goalie.txt"], [footer_src, "footer-template.erb.txt"]].each do |src, base|
414
- dest = File.join(dest_dir, base)
415
- # Use helpers to allow create/replace prompts for these files (question applies to them)
416
- helpers.copy_file_with_prompt(src, dest, allow_create: true, allow_replace: true)
417
- # Ensure readable (0644). These are data/templates, not executables.
418
- begin
419
- File.chmod(0o644, dest) if File.exist?(dest)
420
- rescue StandardError
421
- # ignore permission issues
422
- end
423
- end
424
- else
425
- puts "Skipping copy of .git-hooks templates."
426
- end
427
- end
428
-
429
- # Second: hook scripts — copy only to the local project; prompt only on overwrite
430
- # Per requirements: do not install hook scripts to global ~/.git-hooks
431
- hook_dests = [File.join(project_root, ".git-hooks")]
432
- hook_pairs = [[hook_ruby_src, "commit-msg", 0o755], [hook_sh_src, "prepare-commit-msg", 0o755]]
433
- hook_pairs.each do |src, base, mode|
434
- next unless File.file?(src)
435
- hook_dests.each do |dstdir|
436
- begin
437
- FileUtils.mkdir_p(dstdir)
438
- dest = File.join(dstdir, base)
439
- # Create without prompt if missing; if exists, ask to replace
440
- if File.exist?(dest)
441
- # ask to overwrite
442
- if helpers.ask("Overwrite existing #{dest}?", true)
443
- content = File.read(src)
444
- helpers.write_file(dest, content)
445
- begin
446
- File.chmod(mode, dest)
447
- rescue StandardError
448
- # ignore permission issues
449
- end
450
- puts "Replaced #{dest}"
451
- else
452
- puts "Kept existing #{dest}"
453
- end
454
- else
455
- content = File.read(src)
456
- helpers.write_file(dest, content)
457
- begin
458
- File.chmod(mode, dest)
459
- rescue StandardError
460
- # ignore permission issues
461
- end
462
- puts "Installed #{dest}"
463
- end
464
- rescue StandardError => e
465
- puts "WARNING: Could not install hook #{base} to #{dstdir}: #{e.class}: #{e.message}"
466
- end
467
- end
468
- end
469
- end
7
+ Kettle::Dev::Tasks::TemplateTask.run
470
8
  end
471
9
  end
472
10
  end