kettle-jem 7.0.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.
data/lib/kettle/jem.rb ADDED
@@ -0,0 +1,2528 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "find"
5
+ require "ruby/merge"
6
+ require "token/resolver"
7
+ require "toml-merge"
8
+ require "yaml"
9
+ require "yaml/merge"
10
+ require "ast/merge"
11
+ require_relative "jem/version"
12
+
13
+ module Kettle
14
+ module Jem
15
+ PACKAGE_NAME = "kettle-jem"
16
+ CONTENT_RECIPE_TRANSPORT_VERSION = Ast::Merge::STRUCTURED_EDIT_TRANSPORT_VERSION
17
+ MANAGED_BLOCK_OPEN = "# <<kettle-jem:generated>> do not edit below this line"
18
+ MANAGED_BLOCK_CLOSE = "# <</kettle-jem:generated>>"
19
+ OBSOLETE_GITHUB_WORKFLOWS = %w[ancient.yml legacy.yml supported.yml unsupported.yml main.yml hoary.yml].freeze
20
+ OPENCOLLECTIVE_DISABLED_FILES = %w[.opencollective.yml .github/workflows/opencollective.yml].freeze
21
+ FILE_DELETION_PRIMITIVES = %w[supplied_obsolete_file_deletion supplied_disabled_opencollective_file_deletion].freeze
22
+ PACKAGED_TEMPLATE_ROOT = File.expand_path("jem/templates", __dir__)
23
+ SUPPORTED_TEMPLATE_STRATEGIES = %i[merge accept_template keep_destination raw_copy].freeze
24
+ SUPPORTED_TEMPLATE_FILE_TYPES = %i[ruby gemfile appraisals gemspec rakefile yaml toml markdown text].freeze
25
+ RUBY_TEMPLATE_BASENAMES = %w[Gemfile Rakefile Appraisals Appraisal.root.gemfile .simplecov].freeze
26
+ RUBY_TEMPLATE_SUFFIXES = %w[.gemspec .gemfile].freeze
27
+ RUBY_TEMPLATE_EXTENSIONS = %w[.rb .rake].freeze
28
+ TEMPLATE_TOKEN_CONFIG = Token::Resolver::Config.new(separators: ["|", ":"]).freeze
29
+ EMPTY_TEMPLATE_TOKENS = %w[KJ|COPYRIGHT_PREFIX KJ|MIN_DIVERGENCE_THRESHOLD].freeze
30
+ README_TOP_LOGO_MODE_DEFAULT = "org_and_project"
31
+ README_TOP_LOGO_MODES = %w[org project org_and_project].freeze
32
+ README_DEFAULT_PRESERVE_SECTIONS = ["synopsis", "configuration", "basic usage"].freeze
33
+ README_DEFAULT_PRESERVE_PATTERNS = ["note:*"].freeze
34
+ README_SECTION_ALIASES = {
35
+ "summary" => "synopsis",
36
+ "usage" => "basic usage",
37
+ "configuration options" => "configuration",
38
+ "setup" => "basic usage",
39
+ }.freeze
40
+ README_STATIC_TOP_LOGO_ROW = "[![Galtzo FLOSS Logo by Aboling0, CC BY-SA 4.0][🖼️galtzo-i]][🖼️galtzo-discord] [![ruby-lang Logo, Yukihiro Matsumoto, Ruby Visual Identity Team, CC BY-SA 2.5][🖼️ruby-lang-i]][🖼️ruby-lang]"
41
+ README_STATIC_TOP_LOGO_REFS = [
42
+ "[🖼️galtzo-i]: https://logos.galtzo.com/assets/images/galtzo-floss/avatar-192px.svg",
43
+ "[🖼️galtzo-discord]: https://discord.gg/3qme4XHNKN",
44
+ "[🖼️ruby-lang-i]: https://logos.galtzo.com/assets/images/ruby-lang/avatar-192px.svg",
45
+ "[🖼️ruby-lang]: https://www.ruby-lang.org/",
46
+ ].join("\n").freeze
47
+ RUBOCOP_VERSION_MAP = [
48
+ [Gem::Version.new("1.8"), "~> 0.1"],
49
+ [Gem::Version.new("1.9"), "~> 2.0"],
50
+ [Gem::Version.new("2.0"), "~> 4.0"],
51
+ [Gem::Version.new("2.1"), "~> 6.0"],
52
+ [Gem::Version.new("2.2"), "~> 8.0"],
53
+ [Gem::Version.new("2.3"), "~> 10.0"],
54
+ [Gem::Version.new("2.4"), "~> 12.0"],
55
+ [Gem::Version.new("2.5"), "~> 14.0"],
56
+ [Gem::Version.new("2.6"), "~> 16.0"],
57
+ [Gem::Version.new("2.7"), "~> 18.0"],
58
+ [Gem::Version.new("3.0"), "~> 20.0"],
59
+ [Gem::Version.new("3.1"), "~> 22.0"],
60
+ [Gem::Version.new("3.2"), "~> 24.0"],
61
+ [Gem::Version.new("3.3"), "~> 26.0"],
62
+ [Gem::Version.new("3.4"), "~> 28.0"],
63
+ ].freeze
64
+ FORGE_USER_ENV_KEYS = {
65
+ gh_user: "KJ_GH_USER",
66
+ gl_user: "KJ_GL_USER",
67
+ cb_user: "KJ_CB_USER",
68
+ sh_user: "KJ_SH_USER",
69
+ }.freeze
70
+ FUNDING_TOKEN_ENV_KEYS = {
71
+ patreon: "KJ_FUNDING_PATREON",
72
+ kofi: "KJ_FUNDING_KOFI",
73
+ paypal: "KJ_FUNDING_PAYPAL",
74
+ buymeacoffee: "KJ_FUNDING_BUYMEACOFFEE",
75
+ polar: "KJ_FUNDING_POLAR",
76
+ liberapay: "KJ_FUNDING_LIBERAPAY",
77
+ issuehunt: "KJ_FUNDING_ISSUEHUNT",
78
+ }.freeze
79
+ SOCIAL_TOKEN_ENV_KEYS = {
80
+ mastodon: "KJ_SOCIAL_MASTODON",
81
+ bluesky: "KJ_SOCIAL_BLUESKY",
82
+ linktree: "KJ_SOCIAL_LINKTREE",
83
+ devto: "KJ_SOCIAL_DEVTO",
84
+ }.freeze
85
+ APACHE_LICENSE_COMPAT_CATEGORIES = {
86
+ "Apache-2.0" => :a,
87
+ "MIT" => :a,
88
+ "AGPL-3.0-only" => :x,
89
+ "PolyForm-Noncommercial-1.0.0" => :x,
90
+ "PolyForm-Small-Business-1.0.0" => :x,
91
+ "LicenseRef-Big-Time-Public-License" => :x,
92
+ }.freeze
93
+ APACHE_LICENSE_COMPAT_BADGE_DATA = {
94
+ a: {
95
+ alt: "Apache license compatibility: Category A",
96
+ label: "Apache_Compatible:_Category_A",
97
+ message: "\u2713",
98
+ color: "259D6C",
99
+ ref: "https://www.apache.org/legal/resolved.html#category-a",
100
+ },
101
+ b: {
102
+ alt: "Apache license compatibility: Category B",
103
+ label: "Apache_Maybe_Compatible:_Category_B",
104
+ message: "?",
105
+ color: "D9A407",
106
+ ref: "https://www.apache.org/legal/resolved.html#category-b",
107
+ },
108
+ x: {
109
+ alt: "Apache license compatibility: Category X",
110
+ label: "Apache_Incompatible:_Category_X",
111
+ message: "\u2717",
112
+ color: "C0392B",
113
+ ref: "https://www.apache.org/legal/resolved.html#category-x",
114
+ },
115
+ unknown: {
116
+ alt: "Apache license compatibility: Unknown",
117
+ label: "Apache_Compatibility",
118
+ message: "Unknown",
119
+ color: "6C757D",
120
+ ref: "https://www.apache.org/legal/resolved.html",
121
+ },
122
+ }.freeze
123
+
124
+ module_function
125
+
126
+ def discover_facts(project_root, env: ENV)
127
+ gemspec_path = Dir.glob(File.join(project_root, "*.gemspec")).sort.first
128
+ raise ArgumentError, "no gemspec found in #{project_root}" unless gemspec_path
129
+
130
+ gemspec = File.read(gemspec_path)
131
+ name = extract_gemspec_assignment(gemspec, "spec.name") || File.basename(gemspec_path, ".gemspec")
132
+ homepage_url = extract_gemspec_assignment(gemspec, "spec.homepage")
133
+ metadata_source_url = extract_metadata_value(gemspec, "source_code_uri")
134
+ source_url = concrete_github_url(metadata_source_url) || concrete_github_url(homepage_url) || metadata_source_url || homepage_url
135
+
136
+ kettle_config = kettle_jem_config(project_root)
137
+ author = author_facts(gemspec, kettle_config, env)
138
+ license = license_facts(kettle_config, extract_gemspec_array(gemspec, "spec.licenses"), author_email: author[:email])
139
+ project_runtime = project_runtime_facts(
140
+ kettle_config,
141
+ env,
142
+ package_name: name,
143
+ source_url: source_url,
144
+ author_domain: author[:domain],
145
+ min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"),
146
+ version: extract_gemspec_assignment(gemspec, "spec.version")
147
+ )
148
+ facts = {
149
+ package: compact_hash(
150
+ ecosystem: "rubygems",
151
+ name: name,
152
+ slug: name,
153
+ description: extract_gemspec_assignment(gemspec, "spec.description") ||
154
+ extract_gemspec_assignment(gemspec, "spec.summary"),
155
+ homepage_url: homepage_url,
156
+ source_url: source_url,
157
+ license_expression: license[:expression],
158
+ ),
159
+ rubygems: compact_hash(
160
+ gemspec_path: File.basename(gemspec_path),
161
+ namespace: classify_namespace(name),
162
+ min_ruby: extract_gemspec_assignment(gemspec, "spec.required_ruby_version"),
163
+ ),
164
+ }
165
+ bootstrap = kettle_config_bootstrap_facts(project_root, env)
166
+ facts[:kettle_config_bootstrap] = bootstrap if bootstrap
167
+ facts[:author] = author unless author.empty?
168
+ forge = forge_facts(kettle_config, env)
169
+ facts[:forge] = forge unless forge.empty?
170
+ social = social_facts(kettle_config, env)
171
+ facts[:social] = social unless social.empty?
172
+ opencollective_policy = opencollective_policy(kettle_config, env)
173
+ opencollective_disabled = opencollective_policy.fetch(:disabled)
174
+ open_collective_org = opencollective_org(project_root, env, opencollective_disabled: opencollective_disabled)
175
+ funding = compact_hash(
176
+ urls: funding_urls(
177
+ project_root,
178
+ gemspec,
179
+ name,
180
+ opencollective_disabled: opencollective_disabled,
181
+ open_collective_org: open_collective_org && open_collective_org.fetch(:org)
182
+ )
183
+ )
184
+ funding_tokens = funding_platform_token_facts(kettle_config, env)
185
+ funding[:platform_tokens] = funding_tokens unless funding_tokens.empty?
186
+ funding[:open_collective_disabled] = true if opencollective_disabled
187
+ funding[:open_collective_disabled_source] = opencollective_policy[:source] if opencollective_disabled
188
+ if open_collective_org
189
+ funding[:open_collective_org] = open_collective_org.fetch(:org)
190
+ funding[:open_collective_org_source] = open_collective_org.fetch(:source)
191
+ end
192
+ open_collective_files = opencollective_disabled ? opencollective_disabled_files(project_root) : []
193
+ funding[:open_collective_files] = open_collective_files unless open_collective_files.empty?
194
+ facts[:funding] = funding unless funding.empty?
195
+ facts[:ci] = {
196
+ provider: "github_actions",
197
+ default_branch: "main",
198
+ ruby_versions: github_actions_ruby_versions(facts.fetch(:rubygems).fetch(:min_ruby, nil)),
199
+ obsolete_workflows: github_actions_obsolete_workflows(project_root),
200
+ custom_workflows: github_actions_custom_workflows(project_root, opencollective_disabled: opencollective_disabled),
201
+ }
202
+ coverage_config = github_actions_coverage_config(kettle_config)
203
+ facts[:ci][:coverage] = coverage_config unless coverage_config.empty?
204
+ framework_matrix = github_actions_framework_matrix(kettle_config)
205
+ facts[:ci][:framework_matrix] = framework_matrix unless framework_matrix.empty?
206
+ template_facts = {}
207
+ template_preferences = template_source_preferences(project_root, kettle_config, opencollective_disabled: opencollective_disabled)
208
+ template_facts[:source_preferences] = template_preferences unless template_preferences.empty?
209
+ unless template_preferences.empty?
210
+ facts[:license] = license unless license.empty?
211
+ facts[:project_runtime] = project_runtime unless project_runtime.empty?
212
+ readme_logo = readme_logo_facts(kettle_config, package_name: name, github_org: project_runtime[:github_org])
213
+ facts[:readme_logo] = readme_logo unless readme_logo.empty?
214
+ template_tokens = template_tokens(facts, funding)
215
+ template_facts[:tokens] = template_tokens unless template_tokens.empty?
216
+ end
217
+ facts[:templates] = template_facts unless template_facts.empty?
218
+ facts
219
+ end
220
+
221
+ def recipe_pack(facts)
222
+ recipes = [
223
+ recipe_entry("readme_metadata", "README.md", "markdown", "supplied_readme_metadata_synchronization", facts: %w[package funding readme]),
224
+ recipe_entry("changelog_unreleased", "CHANGELOG.md", "markdown", "changelog_unreleased_normalization", facts: %w[package changelog]),
225
+ recipe_entry("generated_block_sync", "gemfiles/modular/shunted.gemfile", "text", "supplied_managed_text_block_replacement", facts: %w[package generated_blocks]),
226
+ recipe_entry(
227
+ "github_funding_yml",
228
+ ".github/FUNDING.yml",
229
+ "yaml",
230
+ "supplied_github_funding_yaml_synchronization",
231
+ facts: %w[package funding]
232
+ ),
233
+ recipe_entry(
234
+ "github_actions_ci",
235
+ ".github/workflows/ci.yml",
236
+ "yaml",
237
+ "supplied_github_actions_workflow_synchronization",
238
+ facts: %w[package rubygems ci]
239
+ ),
240
+ ]
241
+ if facts[:kettle_config_bootstrap]
242
+ recipes.unshift(kettle_config_bootstrap_recipe(facts.fetch(:kettle_config_bootstrap)))
243
+ end
244
+ if facts.dig(:ci, :framework_matrix)
245
+ recipes << recipe_entry(
246
+ "github_actions_framework_ci",
247
+ ".github/workflows/framework-ci.yml",
248
+ "yaml",
249
+ "supplied_github_actions_framework_workflow_synchronization",
250
+ facts: %w[package rubygems ci]
251
+ )
252
+ end
253
+ if facts.dig(:ci, :coverage)
254
+ recipes << recipe_entry(
255
+ "github_actions_coverage_ci",
256
+ ".github/workflows/coverage.yml",
257
+ "yaml",
258
+ "supplied_github_actions_coverage_workflow_synchronization",
259
+ facts: %w[package rubygems ci]
260
+ )
261
+ end
262
+ facts.dig(:ci, :obsolete_workflows).to_a.each do |workflow_path|
263
+ recipes << recipe_entry(
264
+ "github_actions_obsolete_workflow_cleanup_#{workflow_recipe_slug(workflow_path)}",
265
+ workflow_path,
266
+ "file",
267
+ "supplied_obsolete_file_deletion",
268
+ facts: %w[ci]
269
+ )
270
+ end
271
+ facts.dig(:funding, :open_collective_files).to_a.each do |relative_path|
272
+ recipes << recipe_entry(
273
+ "opencollective_disabled_file_cleanup_#{workflow_recipe_slug(relative_path)}",
274
+ relative_path,
275
+ "file",
276
+ "supplied_disabled_opencollective_file_deletion",
277
+ facts: %w[funding]
278
+ )
279
+ end
280
+ facts.dig(:ci, :custom_workflows).to_a.each do |workflow_path|
281
+ recipes << recipe_entry(
282
+ "github_actions_workflow_snippets_#{workflow_recipe_slug(workflow_path)}",
283
+ workflow_path,
284
+ "yaml",
285
+ "supplied_github_actions_workflow_snippet_merge",
286
+ facts: %w[ci]
287
+ )
288
+ end
289
+ facts.dig(:templates, :source_preferences).to_a.each do |preference|
290
+ apply_template = preference.fetch(:apply, false)
291
+ recipe = recipe_entry(
292
+ "#{apply_template ? "template_source_application" : "template_source_preference"}_#{workflow_recipe_slug(preference.fetch(:target_path))}",
293
+ preference.fetch(:target_path),
294
+ "file",
295
+ apply_template ? "supplied_template_source_application" : "supplied_template_source_preference",
296
+ facts: %w[templates funding]
297
+ )
298
+ recipe[:template_preference] = preference
299
+ recipe[:template_tokens] = facts.dig(:templates, :tokens) if facts.dig(:templates, :tokens)
300
+ recipes << recipe
301
+ end
302
+ recipes << recipe_entry(
303
+ "rakefile_scaffold_cleanup",
304
+ "Rakefile",
305
+ "generic_ast",
306
+ "supplied_source_selector_deletion",
307
+ provider_backend: "generic_structural_owners",
308
+ facts: %w[rubygems rakefile],
309
+ selectors: %w[rakefile_scaffold]
310
+ )
311
+
312
+ {
313
+ name: "kettle-jem-core",
314
+ version: 1,
315
+ ecosystem: "rubygems",
316
+ recipes: recipes,
317
+ }
318
+ end
319
+
320
+ def plan_project(project_root, env: ENV)
321
+ facts = discover_facts(project_root, env: env)
322
+ pack = recipe_pack(facts)
323
+ files = read_project_files(project_root, pack)
324
+ recipe_reports = pack.fetch(:recipes).map do |recipe|
325
+ execute_recipe(project_root: project_root, recipe: recipe, facts: facts, files: files)
326
+ end
327
+ changed_files = recipe_reports.filter_map { |report| report[:relative_path] if report[:changed] }.sort
328
+
329
+ {
330
+ mode: "plan",
331
+ ready: true,
332
+ facts: facts,
333
+ recipe_pack: pack,
334
+ recipe_reports: recipe_reports,
335
+ changed_files: changed_files,
336
+ diagnostics: recipe_reports.flat_map { |report| report[:diagnostics] },
337
+ }
338
+ end
339
+
340
+ def apply_project(project_root, env: ENV)
341
+ report = plan_project(project_root, env: env).merge(mode: "apply")
342
+ report.fetch(:recipe_reports).each do |recipe_report|
343
+ next unless recipe_report[:changed]
344
+
345
+ path = File.join(project_root, recipe_report.fetch(:relative_path))
346
+ if recipe_report.dig(:metadata, :delete_file)
347
+ FileUtils.rm_f(path)
348
+ else
349
+ FileUtils.mkdir_p(File.dirname(path))
350
+ File.write(path, recipe_report.fetch(:final_content))
351
+ end
352
+ end
353
+ report
354
+ end
355
+
356
+ def content_recipe_execution_request(recipe_name:, recipe_version:, relative_path:, provider_family:,
357
+ template_content:, destination_content:, steps:, provider_backend: nil, runtime_context: nil, metadata: nil)
358
+ compact_hash(
359
+ recipe_name: recipe_name.to_s,
360
+ recipe_version: recipe_version.to_s,
361
+ relative_path: relative_path.to_s,
362
+ provider_family: provider_family.to_s,
363
+ provider_backend: provider_backend&.to_s,
364
+ template_content: template_content.to_s,
365
+ destination_content: destination_content.to_s,
366
+ steps: deep_dup(steps),
367
+ runtime_context: deep_dup(runtime_context || {}),
368
+ metadata: deep_dup(metadata || {}),
369
+ )
370
+ end
371
+
372
+ def content_recipe_execution_request_envelope(request)
373
+ {
374
+ kind: "content_recipe_execution_request",
375
+ version: CONTENT_RECIPE_TRANSPORT_VERSION,
376
+ request: deep_dup(request),
377
+ }
378
+ end
379
+
380
+ def content_recipe_execution_report(request:, final_content:, changed:, step_reports:, diagnostics:, metadata: nil)
381
+ compact_hash(
382
+ request: deep_dup(request),
383
+ final_content: final_content.to_s,
384
+ changed: changed ? true : false,
385
+ step_reports: deep_dup(step_reports),
386
+ diagnostics: deep_dup(diagnostics),
387
+ metadata: deep_dup(metadata || {}),
388
+ )
389
+ end
390
+
391
+ def content_recipe_execution_report_envelope(report)
392
+ {
393
+ kind: "content_recipe_execution_report",
394
+ version: CONTENT_RECIPE_TRANSPORT_VERSION,
395
+ report: deep_dup(report),
396
+ }
397
+ end
398
+
399
+ def synchronize_readme(content, facts)
400
+ package = facts.fetch(:package)
401
+ lines = content.to_s.split("\n", -1)
402
+ heading = "# #{package.fetch(:name)}"
403
+ h1_index = lines.index { |line| line.start_with?("# ") }
404
+ unless h1_index
405
+ lines.unshift(heading, "")
406
+ end
407
+ replace_markdown_managed_block(lines.join("\n"), "kettle-jem:metadata", readme_metadata_block(facts))
408
+ end
409
+
410
+ def normalize_changelog(content, facts)
411
+ text = content.to_s
412
+ title = "# Changelog"
413
+ text = "#{title}\n\n#{text}" unless text.lines.first&.start_with?("# ")
414
+ return ensure_trailing_newline(text) if text.match?(/^##\s+\[?Unreleased\]?/i)
415
+
416
+ lines = text.split("\n", -1)
417
+ insert_at = lines.index { |line| line.start_with?("## ") } || lines.length
418
+ section = [
419
+ "## [Unreleased]",
420
+ "",
421
+ "### Added",
422
+ "",
423
+ "### Changed",
424
+ "",
425
+ "### Fixed",
426
+ "",
427
+ ]
428
+ lines.insert(insert_at, *section)
429
+ ensure_trailing_newline(lines.join("\n").gsub(/\n{3,}/, "\n\n"))
430
+ end
431
+
432
+ def synchronize_managed_block(content, facts)
433
+ replacement = [
434
+ MANAGED_BLOCK_OPEN,
435
+ "# package: #{facts.fetch(:package).fetch(:name)}",
436
+ "# generated by kettle-jem vNext",
437
+ MANAGED_BLOCK_CLOSE,
438
+ "",
439
+ ].join("\n")
440
+ replace_text_managed_block(content.to_s, replacement)
441
+ end
442
+
443
+ def execute_recipe(project_root:, recipe:, facts:, files:)
444
+ relative_path = recipe.fetch(:target_path)
445
+ original = files.fetch(relative_path, "")
446
+ deletion = recipe.fetch(:name) == "rakefile_scaffold_cleanup" ? delete_rakefile_scaffold(original) : nil
447
+ final = case recipe.fetch(:name)
448
+ when "readme_metadata"
449
+ synchronize_readme(original, facts)
450
+ when "changelog_unreleased"
451
+ normalize_changelog(original, facts)
452
+ when "generated_block_sync"
453
+ synchronize_managed_block(original, facts)
454
+ when "github_funding_yml"
455
+ synchronize_github_funding_yml(original, facts)
456
+ when "github_actions_ci"
457
+ synchronize_github_actions_ci(original, facts)
458
+ when "github_actions_framework_ci"
459
+ synchronize_github_actions_framework_ci(original, facts)
460
+ when "github_actions_coverage_ci"
461
+ synchronize_github_actions_coverage_ci(original, facts)
462
+ when /\Agithub_actions_obsolete_workflow_cleanup_/
463
+ ""
464
+ when /\Aopencollective_disabled_file_cleanup_/
465
+ ""
466
+ when /\Agithub_actions_workflow_snippets_/
467
+ synchronize_github_actions_workflow_snippets(original)
468
+ when "kettle_config_bootstrap"
469
+ apply_kettle_config_bootstrap(project_root, recipe)
470
+ when /\Atemplate_source_preference_/
471
+ original
472
+ when /\Atemplate_source_application_/
473
+ apply_template_source(project_root, recipe, original)
474
+ when "rakefile_scaffold_cleanup"
475
+ deletion.fetch(:content)
476
+ else
477
+ original
478
+ end
479
+
480
+ template_content = recipe_template_content(project_root, recipe)
481
+ request = content_recipe_execution_request(
482
+ recipe_name: recipe.fetch(:primitive),
483
+ recipe_version: "1",
484
+ relative_path: relative_path,
485
+ provider_family: recipe.fetch(:provider_family),
486
+ provider_backend: recipe[:provider_backend],
487
+ template_content: template_content,
488
+ destination_content: original,
489
+ steps: [content_recipe_step(recipe)],
490
+ runtime_context: recipe_runtime_context(recipe, facts, deletion),
491
+ metadata: { packaging_recipe: recipe.fetch(:name), project_root: project_root.to_s },
492
+ )
493
+ changed = delete_file_recipe?(recipe) || final != original
494
+ step_report = content_recipe_step_report(recipe: recipe, request: request, original: original, final: final, changed: changed, deletion: deletion)
495
+ report = content_recipe_execution_report(
496
+ request: request,
497
+ final_content: final,
498
+ changed: changed,
499
+ step_reports: [step_report],
500
+ diagnostics: [],
501
+ metadata: recipe_report_metadata(recipe),
502
+ )
503
+
504
+ {
505
+ recipe_name: recipe.fetch(:name),
506
+ relative_path: relative_path,
507
+ changed: changed,
508
+ request_envelope: content_recipe_execution_request_envelope(request),
509
+ report_envelope: content_recipe_execution_report_envelope(report),
510
+ final_content: final,
511
+ metadata: recipe_report_metadata(recipe),
512
+ diagnostics: [],
513
+ }
514
+ end
515
+
516
+ def content_recipe_step(recipe)
517
+ step = {
518
+ step_id: recipe.fetch(:name),
519
+ step_kind: recipe.fetch(:primitive),
520
+ name: recipe.fetch(:name),
521
+ provider_family: recipe.fetch(:provider_family),
522
+ metadata: { target_path: recipe.fetch(:target_path) },
523
+ }
524
+ step[:provider_backend] = recipe[:provider_backend] if recipe[:provider_backend]
525
+ if recipe.fetch(:primitive) == "supplied_source_selector_deletion"
526
+ step[:step_kind] = "native_policy"
527
+ step[:policy] = {
528
+ policy_kind: "delete_supplied_structural_owners",
529
+ required_context: "delete_selectors",
530
+ operation: "delete",
531
+ selector_family: "structural_owner_range",
532
+ normalize_blank_lines: true,
533
+ }
534
+ end
535
+ step
536
+ end
537
+
538
+ def content_recipe_step_report(recipe:, request:, original:, final:, changed:, deletion: nil)
539
+ operation_profile = Ast::Merge.structured_edit_operation_profile(
540
+ operation_kind: recipe.fetch(:primitive),
541
+ known_operation_kind: true,
542
+ source_requirement: "destination_content",
543
+ destination_requirement: "relative_path",
544
+ replacement_source: "runtime_context",
545
+ captures_source_text: false,
546
+ supports_if_missing: true,
547
+ operation_family: "kettle-jem",
548
+ )
549
+ result = Ast::Merge.structured_edit_result(
550
+ operation_kind: recipe.fetch(:primitive),
551
+ updated_content: final,
552
+ changed: changed,
553
+ operation_profile: operation_profile,
554
+ )
555
+ application = Ast::Merge.structured_edit_application(request: request, result: result)
556
+ {
557
+ step_id: recipe.fetch(:name),
558
+ step_kind: recipe.fetch(:primitive),
559
+ status: changed ? "applied" : "unchanged",
560
+ changed: changed,
561
+ input_content: original,
562
+ output_content: final,
563
+ application: application,
564
+ diagnostics: [],
565
+ metadata: step_report_metadata(recipe, deletion),
566
+ }
567
+ end
568
+
569
+ def read_project_files(project_root, pack)
570
+ pack.fetch(:recipes).to_h do |recipe|
571
+ relative_path = recipe.fetch(:target_path)
572
+ path = File.join(project_root, relative_path)
573
+ [relative_path, File.exist?(path) ? File.read(path) : ""]
574
+ end
575
+ end
576
+
577
+ def recipe_template_content(project_root, recipe)
578
+ return "" unless %w[
579
+ supplied_kettle_config_bootstrap
580
+ supplied_template_source_preference
581
+ supplied_template_source_application
582
+ ].include?(recipe.fetch(:primitive))
583
+
584
+ preference = recipe.fetch(:template_preference)
585
+ path = File.join(
586
+ preference.fetch(:source_root_path, project_root),
587
+ preference.fetch(:source_relative_path, preference.fetch(:selected_source))
588
+ )
589
+ File.read(path)
590
+ end
591
+
592
+ def apply_template_source(project_root, recipe, original)
593
+ strategy = recipe.dig(:template_preference, :strategy).to_s
594
+ return original if strategy == "keep_destination"
595
+
596
+ content = recipe_template_content(project_root, recipe)
597
+ return content if strategy == "raw_copy"
598
+
599
+ resolved = resolve_template_tokens(
600
+ content,
601
+ recipe.fetch(:template_tokens, {}),
602
+ scan_unresolved: unresolved_template_scan?(recipe)
603
+ )
604
+ rescue ArgumentError => e
605
+ raise ArgumentError, "#{recipe.fetch(:target_path)}: #{e.message}"
606
+ else
607
+ if recipe.fetch(:target_path) == "README.md" && (strategy.empty? || strategy == "merge")
608
+ return merge_readme_template(
609
+ template_content: resolved,
610
+ destination_content: original,
611
+ preserve_config: recipe.dig(:template_preference, :readme_preserve_config) || {}
612
+ )
613
+ end
614
+ return merge_config_template_source(recipe, resolved, original) if strategy.empty? || strategy == "merge"
615
+
616
+ resolved
617
+ end
618
+
619
+ def merge_config_template_source(recipe, template_content, destination_content)
620
+ file_type = template_file_type(recipe)
621
+ return template_content if destination_content.to_s.strip.empty?
622
+ return destination_content if destination_content == template_content
623
+
624
+ case file_type
625
+ when :gemspec
626
+ return merge_gemspec_template_source(template_content, destination_content)
627
+ when :ruby, :gemfile, :appraisals, :rakefile
628
+ merge_result = Ruby::Merge.merge_ruby(
629
+ template_content,
630
+ destination_content,
631
+ "ruby",
632
+ merge_template_requires: file_type == :rakefile
633
+ )
634
+ when :yaml
635
+ merge_result = Yaml::Merge.merge_yaml(template_content, destination_content, "yaml")
636
+ when :toml
637
+ merge_result = Toml::Merge.merge_toml(template_content, destination_content, "toml")
638
+ else
639
+ return template_content
640
+ end
641
+ return merge_result.fetch(:output) if merge_result[:ok]
642
+
643
+ diagnostics = merge_result.fetch(:diagnostics, [])
644
+ message = diagnostics.map { |diagnostic| diagnostic[:message] || diagnostic["message"] }.compact.join("; ")
645
+ raise ArgumentError, "failed to merge #{file_type} template #{recipe.fetch(:target_path)}: #{message}"
646
+ end
647
+
648
+ def merge_gemspec_template_source(template_content, destination_content)
649
+ replacements = gemspec_preserved_assignments(destination_content)
650
+ merged = replacements.reduce(template_content.dup) do |content, (field, source_line)|
651
+ pattern = /^(\s*spec\.#{Regexp.escape(field)}\s*=\s*).*$/
652
+ content.match?(pattern) ? content.sub(pattern, source_line.rstrip) : content
653
+ end
654
+ preserve_gemspec_dependency_lines(merged, destination_content)
655
+ end
656
+
657
+ def gemspec_preserved_assignments(source)
658
+ %w[
659
+ name
660
+ authors
661
+ email
662
+ summary
663
+ description
664
+ homepage
665
+ licenses
666
+ required_ruby_version
667
+ executables
668
+ ].each_with_object({}) do |field, assignments|
669
+ line = source.to_s.lines.find { |candidate| candidate.match?(/^\s*spec\.#{Regexp.escape(field)}\s*=/) }
670
+ next unless line
671
+ next if line.include?("TODO:")
672
+
673
+ assignments[field] = line
674
+ end
675
+ end
676
+
677
+ def preserve_gemspec_dependency_lines(template_content, destination_content)
678
+ destination_dependencies = gemspec_dependency_line_index(destination_content)
679
+ return template_content if destination_dependencies.empty?
680
+
681
+ merged = replace_matching_gemspec_dependency_lines(template_content, destination_dependencies)
682
+ append_missing_gemspec_dependency_lines(merged, destination_dependencies)
683
+ end
684
+
685
+ def replace_matching_gemspec_dependency_lines(content, destination_dependencies)
686
+ content.to_s.lines.map do |line|
687
+ key = gemspec_dependency_line_key(line)
688
+ key && destination_dependencies[key] ? destination_dependencies[key] : line
689
+ end.join
690
+ end
691
+
692
+ def append_missing_gemspec_dependency_lines(content, destination_dependencies)
693
+ existing_keys = gemspec_dependency_line_index(content).keys
694
+ missing_lines = destination_dependencies.reject { |key, _line| existing_keys.include?(key) }.values
695
+ return content if missing_lines.empty?
696
+
697
+ content.sub(/^end\s*\z/, "#{missing_lines.join}end")
698
+ end
699
+
700
+ def gemspec_dependency_line_index(source)
701
+ source.to_s.lines.each_with_object({}) do |line, dependencies|
702
+ key = gemspec_dependency_line_key(line)
703
+ dependencies[key] ||= line if key
704
+ end
705
+ end
706
+
707
+ def gemspec_dependency_line_key(line)
708
+ match = line.to_s.match(/^\s*spec\.(add_(?:development_|runtime_)?dependency)\s*\(?\s*["']([^"']+)["']/)
709
+ match && [match[1], match[2]]
710
+ end
711
+
712
+ def template_file_type(recipe)
713
+ configured = recipe.dig(:template_preference, :file_type).to_s
714
+ return configured.to_sym unless configured.empty?
715
+
716
+ relative_path = recipe.fetch(:target_path).to_s
717
+ basename = File.basename(relative_path)
718
+ extension = File.extname(relative_path).downcase
719
+ return :gemfile if basename == "Gemfile" || basename.end_with?(".gemfile")
720
+ return :appraisals if basename.start_with?("Appraisals") || basename == "Appraisal.root.gemfile"
721
+ return :gemspec if basename.end_with?(".gemspec")
722
+ return :rakefile if basename == "Rakefile" || extension == ".rake"
723
+ return :ruby if RUBY_TEMPLATE_BASENAMES.include?(basename) ||
724
+ RUBY_TEMPLATE_SUFFIXES.any? { |suffix| basename.end_with?(suffix) } ||
725
+ RUBY_TEMPLATE_EXTENSIONS.include?(extension)
726
+ return :yaml if extension.match?(/\A\.ya?ml\z/) || File.basename(relative_path).casecmp("citation.cff").zero?
727
+ return :toml if extension == ".toml"
728
+ return :markdown if extension.match?(/\A\.md(?:own)?\z/)
729
+
730
+ :text
731
+ end
732
+
733
+ def apply_kettle_config_bootstrap(project_root, recipe)
734
+ content = recipe_template_content(project_root, recipe)
735
+ tokens = stringify_template_tokens(recipe.fetch(:template_tokens, {}))
736
+ content.gsub("{KJ|MIN_DIVERGENCE_THRESHOLD}", tokens.fetch("KJ|MIN_DIVERGENCE_THRESHOLD", ""))
737
+ end
738
+
739
+ def recipe_report_metadata(recipe)
740
+ metadata = { packaging_recipe: recipe.fetch(:name) }
741
+ metadata[:delete_file] = true if delete_file_recipe?(recipe)
742
+ metadata[:template_source_preference] = deep_dup(recipe[:template_preference]) if recipe[:template_preference]
743
+ metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens]
744
+ metadata[:bootstrap_file] = true if recipe.fetch(:primitive) == "supplied_kettle_config_bootstrap"
745
+ metadata
746
+ end
747
+
748
+ def recipe_entry(name, target_path, provider_family, primitive, facts:, provider_backend: nil, selectors: [])
749
+ {
750
+ name: name,
751
+ target_path: target_path,
752
+ provider_family: provider_family,
753
+ provider_backend: provider_backend,
754
+ primitive: primitive,
755
+ facts: facts,
756
+ selectors: selectors,
757
+ }
758
+ end
759
+
760
+ def recipe_runtime_context(recipe, facts, deletion)
761
+ context = deep_dup(facts)
762
+ if recipe.fetch(:primitive) == "supplied_source_selector_deletion" && deletion
763
+ context[:delete_selectors] = deletion.fetch(:delete_selectors)
764
+ end
765
+ context[:template_source_preference] = deep_dup(recipe[:template_preference]) if recipe[:template_preference]
766
+ context[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens]
767
+ context
768
+ end
769
+
770
+ def step_report_metadata(recipe, deletion)
771
+ metadata = { target_path: recipe.fetch(:target_path) }
772
+ if recipe.fetch(:primitive) == "supplied_obsolete_file_deletion"
773
+ metadata.merge!(
774
+ policy_kind: "delete_obsolete_file",
775
+ operation: "delete",
776
+ deleted_file: recipe.fetch(:target_path),
777
+ )
778
+ end
779
+ if recipe.fetch(:primitive) == "supplied_disabled_opencollective_file_deletion"
780
+ metadata.merge!(
781
+ policy_kind: "delete_disabled_opencollective_file",
782
+ operation: "delete",
783
+ deleted_file: recipe.fetch(:target_path),
784
+ )
785
+ end
786
+ if recipe.fetch(:primitive) == "supplied_template_source_preference"
787
+ metadata.merge!(
788
+ policy_kind: "select_template_source",
789
+ operation: "select",
790
+ template_source_preference: deep_dup(recipe.fetch(:template_preference)),
791
+ )
792
+ metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens]
793
+ end
794
+ if recipe.fetch(:primitive) == "supplied_template_source_application"
795
+ metadata.merge!(
796
+ policy_kind: "apply_template_source",
797
+ operation: "replace",
798
+ template_source_preference: deep_dup(recipe.fetch(:template_preference)),
799
+ )
800
+ metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens]
801
+ end
802
+ if recipe.fetch(:primitive) == "supplied_kettle_config_bootstrap"
803
+ metadata.merge!(
804
+ policy_kind: "bootstrap_kettle_config",
805
+ operation: "create",
806
+ template_source_preference: deep_dup(recipe.fetch(:template_preference)),
807
+ )
808
+ metadata[:template_tokens] = deep_dup(recipe[:template_tokens]) if recipe[:template_tokens]
809
+ end
810
+ return metadata unless deletion
811
+
812
+ metadata.merge(
813
+ policy_kind: "delete_supplied_structural_owners",
814
+ operation: "delete",
815
+ consumed_context: "delete_selectors",
816
+ deleted_ranges: deletion.fetch(:delete_selectors).length,
817
+ deleted_selector_ids: deletion.fetch(:delete_selectors).map { |selector| selector.fetch(:selector_id) },
818
+ )
819
+ end
820
+
821
+ def extract_gemspec_assignment(source, field)
822
+ match = source.match(/#{Regexp.escape(field)}\s*=\s*["']([^"']*)["']/)
823
+ match && match[1]
824
+ end
825
+
826
+ def extract_gemspec_array(source, field)
827
+ match = source.match(/#{Regexp.escape(field)}\s*=\s*\[([^\]]*)\]/m)
828
+ return [] unless match
829
+
830
+ match[1].scan(/["']([^"']+)["']/).flatten
831
+ end
832
+
833
+ def extract_metadata_value(source, key)
834
+ match = source.match(/spec\.metadata\[\s*["']#{Regexp.escape(key)}["']\s*\]\s*=\s*["']([^"']*)["']/)
835
+ match && match[1]
836
+ end
837
+
838
+ def funding_urls(project_root, gemspec_source, package_name, opencollective_disabled: false, open_collective_org: nil)
839
+ urls = [extract_metadata_value(gemspec_source, "funding_uri")]
840
+ path = File.join(project_root, ".github", "FUNDING.yml")
841
+ urls.concat(github_funding_urls(path, opencollective_disabled: opencollective_disabled)) if File.exist?(path)
842
+ urls << github_funding_platform_urls("open_collective", [open_collective_org]).first unless opencollective_disabled
843
+ urls << github_funding_platform_urls("tidelift", ["rubygems/#{package_name}"]).first
844
+
845
+ urls.compact.uniq.sort
846
+ end
847
+
848
+ def github_funding_urls(path, opencollective_disabled: false)
849
+ funding = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {}
850
+ return [] unless funding.is_a?(Hash)
851
+
852
+ funding.flat_map do |platform, value|
853
+ next [] if opencollective_disabled && platform.to_s == "open_collective"
854
+
855
+ github_funding_platform_urls(platform.to_s, Array(value).compact)
856
+ end
857
+ end
858
+
859
+ def github_funding_platform_urls(platform, values)
860
+ values.filter_map do |value|
861
+ handle = value.to_s.strip.delete_prefix("@")
862
+ next if handle.empty?
863
+
864
+ case platform
865
+ when "buy_me_a_coffee"
866
+ "https://www.buymeacoffee.com/#{handle}"
867
+ when "custom"
868
+ handle if handle.match?(%r{\Ahttps?://})
869
+ when "github"
870
+ "https://github.com/sponsors/#{handle}"
871
+ when "issuehunt"
872
+ "https://issuehunt.io/u/#{handle}"
873
+ when "ko_fi"
874
+ "https://ko-fi.com/#{handle}"
875
+ when "liberapay"
876
+ "https://liberapay.com/#{handle}/donate"
877
+ when "open_collective"
878
+ "https://opencollective.com/#{handle}"
879
+ when "patreon"
880
+ "https://patreon.com/#{handle}"
881
+ when "polar"
882
+ "https://polar.sh/#{handle}"
883
+ when "thanks_dev"
884
+ "https://thanks.dev/#{handle}"
885
+ when "tidelift"
886
+ "https://tidelift.com/funding/github/#{handle}"
887
+ end
888
+ end
889
+ end
890
+
891
+ def github_actions_ruby_versions(min_ruby)
892
+ floor = min_ruby.to_s[/\d+\.\d+/] || "3.1"
893
+ candidates = %w[3.1 3.2 3.3 3.4]
894
+ selected = candidates.select { |version| Gem::Version.new(version) >= Gem::Version.new(floor) }
895
+ selected.empty? ? [floor] : selected
896
+ end
897
+
898
+ def github_actions_custom_workflows(project_root, opencollective_disabled: false)
899
+ workflow_root = File.join(project_root, ".github", "workflows")
900
+ return [] unless Dir.exist?(workflow_root)
901
+
902
+ Dir.glob(File.join(workflow_root, "*.{yml,yaml}")).filter_map do |path|
903
+ relative_path = path.delete_prefix("#{project_root}/")
904
+ next if opencollective_disabled && opencollective_disabled_file?(relative_path)
905
+ next if generated_or_obsolete_github_workflow?(relative_path)
906
+
907
+ relative_path
908
+ end.sort
909
+ end
910
+
911
+ def github_actions_obsolete_workflows(project_root)
912
+ workflow_root = File.join(project_root, ".github", "workflows")
913
+ OBSOLETE_GITHUB_WORKFLOWS.filter_map do |workflow|
914
+ relative_path = ".github/workflows/#{workflow}"
915
+ path = File.join(workflow_root, workflow)
916
+ relative_path if File.exist?(path)
917
+ end.sort
918
+ end
919
+
920
+ def generated_or_obsolete_github_workflow?(relative_path)
921
+ return true if %w[.github/workflows/ci.yml .github/workflows/coverage.yml .github/workflows/framework-ci.yml].include?(relative_path)
922
+
923
+ OBSOLETE_GITHUB_WORKFLOWS.include?(File.basename(relative_path))
924
+ end
925
+
926
+ def opencollective_disabled_files(project_root)
927
+ OPENCOLLECTIVE_DISABLED_FILES.select do |relative_path|
928
+ File.exist?(File.join(project_root, relative_path))
929
+ end
930
+ end
931
+
932
+ def opencollective_disabled_file?(relative_path)
933
+ OPENCOLLECTIVE_DISABLED_FILES.include?(relative_path.to_s)
934
+ end
935
+
936
+ def delete_file_recipe?(recipe)
937
+ FILE_DELETION_PRIMITIVES.include?(recipe.fetch(:primitive))
938
+ end
939
+
940
+ def workflow_recipe_slug(workflow_path)
941
+ workflow_path.gsub(/[^a-zA-Z0-9]+/, "_").gsub(/\A_+|_+\z/, "")
942
+ end
943
+
944
+ def kettle_jem_config(project_root)
945
+ path = File.join(project_root, ".kettle-jem.yml")
946
+ return {} unless File.exist?(path)
947
+
948
+ config = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {}
949
+ config.is_a?(Hash) ? config : {}
950
+ end
951
+
952
+ def opencollective_disabled?(config, env: ENV)
953
+ opencollective_policy(config, env).fetch(:disabled)
954
+ end
955
+
956
+ def opencollective_policy(config, env)
957
+ funding = config["funding"]
958
+ if funding.is_a?(Hash) && funding.key?("open_collective")
959
+ config_value = funding["open_collective"]
960
+ return {
961
+ disabled: falsey_config?(config_value),
962
+ source: "config.funding.open_collective",
963
+ value: config_value.to_s,
964
+ }
965
+ end
966
+
967
+ env_falsey = opencollective_falsey_env(env)
968
+ return { disabled: true, source: "env.#{env_falsey.fetch(:key)}", value: env_falsey.fetch(:value).to_s } if env_falsey
969
+
970
+ { disabled: false }
971
+ end
972
+
973
+ def opencollective_falsey_env(env)
974
+ %w[OPENCOLLECTIVE_HANDLE FUNDING_ORG].each do |key|
975
+ value = env[key]
976
+ return { key: key, value: value } if falsey_config?(value)
977
+ end
978
+ nil
979
+ end
980
+
981
+ def opencollective_org(project_root, env, opencollective_disabled: false)
982
+ return nil if opencollective_disabled
983
+
984
+ env_org = opencollective_org_env(env)
985
+ return env_org if env_org
986
+
987
+ opencollective_org_file(project_root)
988
+ end
989
+
990
+ def opencollective_org_env(env)
991
+ %w[OPENCOLLECTIVE_HANDLE FUNDING_ORG].each do |key|
992
+ value = env[key].to_s.strip
993
+ next if value.empty? || falsey_config?(value)
994
+
995
+ return { org: value, source: "env.#{key}" }
996
+ end
997
+ nil
998
+ end
999
+
1000
+ def opencollective_org_file(project_root)
1001
+ path = File.join(project_root, ".opencollective.yml")
1002
+ return nil unless File.exist?(path)
1003
+
1004
+ config = YAML.safe_load(File.read(path), permitted_classes: [], aliases: false) || {}
1005
+ return nil unless config.is_a?(Hash)
1006
+
1007
+ org = config.fetch("collective", config["org"]).to_s.strip
1008
+ return nil if org.empty?
1009
+
1010
+ { org: org, source: ".opencollective.yml" }
1011
+ end
1012
+
1013
+ def template_tokens(facts, funding)
1014
+ package = facts.fetch(:package)
1015
+ rubygems = facts.fetch(:rubygems)
1016
+ tokens = {
1017
+ "KJ|GEM_NAME" => package.fetch(:name).to_s,
1018
+ "KJ|GEM_NAME_PATH" => package.fetch(:name).to_s.tr("-", "/"),
1019
+ "KJ|GEM_SHIELD" => shield_token(package.fetch(:name).to_s),
1020
+ "KJ|GEM_MAJOR" => gem_major_token(facts.fetch(:project_runtime, {})[:version]),
1021
+ "KJ|GH_ORG" => facts.fetch(:project_runtime, {})[:github_org].to_s,
1022
+ "KJ|NAMESPACE" => rubygems.fetch(:namespace).to_s,
1023
+ "KJ|NAMESPACE_SHIELD" => shield_token(rubygems.fetch(:namespace).to_s),
1024
+ "KJ|MIN_RUBY" => minimum_ruby_token(rubygems[:min_ruby]),
1025
+ "KJ|MIN_DEV_RUBY" => minimum_dev_ruby_token(rubygems[:min_ruby]),
1026
+ }.merge(
1027
+ rubocop_template_tokens(rubygems[:min_ruby])
1028
+ ).merge(
1029
+ author_template_tokens(facts.fetch(:author, {}))
1030
+ ).merge(
1031
+ forge_template_tokens(facts.fetch(:forge, {}))
1032
+ ).merge(
1033
+ funding_template_tokens(funding)
1034
+ ).merge(
1035
+ social_template_tokens(facts.fetch(:social, {}))
1036
+ ).merge(
1037
+ license_template_tokens(facts.fetch(:license, {}))
1038
+ ).merge(
1039
+ project_runtime_template_tokens(facts.fetch(:project_runtime, {}))
1040
+ ).merge(
1041
+ readme_logo_template_tokens(facts.fetch(:readme_logo, {}))
1042
+ )
1043
+ org = funding[:open_collective_org].to_s
1044
+ tokens["KJ|OPENCOLLECTIVE_ORG"] = org unless org.empty?
1045
+
1046
+ tokens.reject { |key, value| value.empty? && !EMPTY_TEMPLATE_TOKENS.include?(key) }
1047
+ end
1048
+
1049
+ def minimum_ruby_token(requirement)
1050
+ requirement.to_s[/\d+(?:\.\d+){1,2}/].to_s
1051
+ end
1052
+
1053
+ def minimum_dev_ruby_token(requirement)
1054
+ min_ruby = minimum_ruby_token(requirement)
1055
+ return "" if min_ruby.empty?
1056
+
1057
+ [Gem::Version.new(min_ruby), Gem::Version.new("2.3")].max.to_s
1058
+ rescue ArgumentError
1059
+ "2.3"
1060
+ end
1061
+
1062
+ def gem_major_token(version)
1063
+ Gem::Version.new(version.to_s).segments.first.to_s
1064
+ rescue ArgumentError
1065
+ "0"
1066
+ end
1067
+
1068
+ def author_facts(gemspec_source, config, env)
1069
+ token_config = token_config_values(config)
1070
+ author_config = token_config["author"].is_a?(Hash) ? token_config["author"] : {}
1071
+ derived_name = extract_gemspec_array(gemspec_source, "spec.authors").first
1072
+ derived_email = extract_gemspec_array(gemspec_source, "spec.email").first
1073
+ name = preferred_template_token_value(derived_name, author_config["name"], env, "KJ_AUTHOR_NAME").to_s
1074
+ email = preferred_template_token_value(derived_email, author_config["email"], env, "KJ_AUTHOR_EMAIL").to_s
1075
+ given_names = preferred_template_token_value(author_given_names(name), author_config["given_names"], env, "KJ_AUTHOR_GIVEN_NAMES")
1076
+ family_names = preferred_template_token_value(author_family_names(name), author_config["family_names"], env, "KJ_AUTHOR_FAMILY_NAMES")
1077
+ domain = preferred_template_token_value(email.split("@", 2)[1], author_config["domain"], env, "KJ_AUTHOR_DOMAIN")
1078
+ orcid = preferred_template_token_value(nil, author_config["orcid"], env, "KJ_AUTHOR_ORCID")
1079
+ compact_hash(
1080
+ name: name,
1081
+ given_names: given_names.to_s,
1082
+ family_names: family_names.to_s,
1083
+ email: email,
1084
+ domain: domain.to_s,
1085
+ orcid: orcid.to_s
1086
+ )
1087
+ end
1088
+
1089
+ def token_config_values(config)
1090
+ raw = config.is_a?(Hash) ? config["tokens"] : nil
1091
+ raw.is_a?(Hash) ? raw : {}
1092
+ end
1093
+
1094
+ def preferred_template_token_value(derived_value, config_value, env, env_key)
1095
+ env_clean = env[env_key].to_s.strip
1096
+ return env_clean if present_template_token_value?(env_clean)
1097
+
1098
+ config_clean = config_value.to_s.strip
1099
+ return config_clean if present_template_token_value?(config_clean)
1100
+ return unless present_template_token_value?(derived_value)
1101
+
1102
+ derived_value.to_s.strip
1103
+ end
1104
+
1105
+ def present_template_token_value?(value)
1106
+ clean = value.to_s.strip
1107
+ !clean.empty? && !token_placeholder?(clean)
1108
+ end
1109
+
1110
+ def token_placeholder?(value)
1111
+ value.to_s.strip.match?(%r{\A\{KJ\|[A-Z][A-Z0-9_:]*\}\z})
1112
+ end
1113
+
1114
+ def author_template_tokens(author)
1115
+ {
1116
+ "KJ|AUTHOR:NAME" => author[:name].to_s,
1117
+ "KJ|AUTHOR:GIVEN_NAMES" => author[:given_names].to_s,
1118
+ "KJ|AUTHOR:FAMILY_NAMES" => author[:family_names].to_s,
1119
+ "KJ|AUTHOR:EMAIL" => author[:email].to_s,
1120
+ "KJ|AUTHOR:DOMAIN" => author[:domain].to_s,
1121
+ "KJ|AUTHOR:ORCID" => author[:orcid].to_s,
1122
+ }
1123
+ end
1124
+
1125
+ def forge_facts(config, env)
1126
+ token_config = token_config_values(config)
1127
+ forge_config = token_config["forge"].is_a?(Hash) ? token_config["forge"] : {}
1128
+ compact_hash(
1129
+ gh_user: forge_user_value(forge_config, env, :gh_user).to_s,
1130
+ gl_user: forge_user_value(forge_config, env, :gl_user).to_s,
1131
+ cb_user: forge_user_value(forge_config, env, :cb_user).to_s,
1132
+ sh_user: forge_user_value(forge_config, env, :sh_user).to_s
1133
+ )
1134
+ end
1135
+
1136
+ def forge_user_value(forge_config, env, key)
1137
+ preferred_template_token_value(nil, forge_config[key.to_s], env, FORGE_USER_ENV_KEYS.fetch(key))
1138
+ end
1139
+
1140
+ def forge_template_tokens(forge)
1141
+ {
1142
+ "KJ|GH:USER" => forge[:gh_user].to_s,
1143
+ "KJ|GL:USER" => forge[:gl_user].to_s,
1144
+ "KJ|CB:USER" => forge[:cb_user].to_s,
1145
+ "KJ|SH:USER" => forge[:sh_user].to_s,
1146
+ }
1147
+ end
1148
+
1149
+ def funding_platform_token_facts(config, env)
1150
+ token_config = token_config_values(config)
1151
+ funding_config = token_config["funding"].is_a?(Hash) ? token_config["funding"] : {}
1152
+ compact_hash(
1153
+ patreon: funding_platform_token_value(funding_config, env, :patreon).to_s,
1154
+ kofi: funding_platform_token_value(funding_config, env, :kofi).to_s,
1155
+ paypal: funding_platform_token_value(funding_config, env, :paypal).to_s,
1156
+ buymeacoffee: funding_platform_token_value(funding_config, env, :buymeacoffee).to_s,
1157
+ polar: funding_platform_token_value(funding_config, env, :polar).to_s,
1158
+ liberapay: funding_platform_token_value(funding_config, env, :liberapay).to_s,
1159
+ issuehunt: funding_platform_token_value(funding_config, env, :issuehunt).to_s
1160
+ )
1161
+ end
1162
+
1163
+ def funding_platform_token_value(funding_config, env, key)
1164
+ preferred_template_token_value(nil, funding_config[key.to_s], env, FUNDING_TOKEN_ENV_KEYS.fetch(key))
1165
+ end
1166
+
1167
+ def funding_template_tokens(funding)
1168
+ platform_tokens = funding.fetch(:platform_tokens, {})
1169
+ {
1170
+ "KJ|FUNDING:PATREON" => platform_tokens[:patreon].to_s,
1171
+ "KJ|FUNDING:KOFI" => platform_tokens[:kofi].to_s,
1172
+ "KJ|FUNDING:PAYPAL" => platform_tokens[:paypal].to_s,
1173
+ "KJ|FUNDING:BUYMEACOFFEE" => platform_tokens[:buymeacoffee].to_s,
1174
+ "KJ|FUNDING:POLAR" => platform_tokens[:polar].to_s,
1175
+ "KJ|FUNDING:LIBERAPAY" => platform_tokens[:liberapay].to_s,
1176
+ "KJ|FUNDING:ISSUEHUNT" => platform_tokens[:issuehunt].to_s,
1177
+ }
1178
+ end
1179
+
1180
+ def social_facts(config, env)
1181
+ token_config = token_config_values(config)
1182
+ social_config = token_config["social"].is_a?(Hash) ? token_config["social"] : {}
1183
+ compact_hash(
1184
+ mastodon: social_token_value(social_config, env, :mastodon).to_s,
1185
+ bluesky: social_token_value(social_config, env, :bluesky).to_s,
1186
+ linktree: social_token_value(social_config, env, :linktree).to_s,
1187
+ devto: social_token_value(social_config, env, :devto).to_s
1188
+ )
1189
+ end
1190
+
1191
+ def social_token_value(social_config, env, key)
1192
+ preferred_template_token_value(nil, social_config[key.to_s], env, SOCIAL_TOKEN_ENV_KEYS.fetch(key))
1193
+ end
1194
+
1195
+ def social_template_tokens(social)
1196
+ {
1197
+ "KJ|SOCIAL:MASTODON" => social[:mastodon].to_s,
1198
+ "KJ|SOCIAL:BLUESKY" => social[:bluesky].to_s,
1199
+ "KJ|SOCIAL:LINKTREE" => social[:linktree].to_s,
1200
+ "KJ|SOCIAL:DEVTO" => social[:devto].to_s,
1201
+ }
1202
+ end
1203
+
1204
+ def project_runtime_facts(config, env, package_name:, source_url:, author_domain:, min_ruby:, version:)
1205
+ run_timestamp = Time.now
1206
+ compact_hash(
1207
+ freeze_token: config.dig("defaults", "freeze_token").to_s.empty? ? "kettle-jem" : config.dig("defaults", "freeze_token").to_s,
1208
+ kettle_jem_version: VERSION,
1209
+ template_run_date: run_timestamp.strftime("%Y-%m-%d"),
1210
+ template_run_year: run_timestamp.year.to_s,
1211
+ kettle_dev_gem: "kettle-dev",
1212
+ yard_host: "#{package_name.to_s.tr("_", "-")}.#{author_domain.to_s.empty? ? "example.com" : author_domain}",
1213
+ project_emoji: preferred_template_token_value(nil, config["project_emoji"], env, "KJ_PROJECT_EMOJI").to_s,
1214
+ min_divergence_threshold: preferred_template_token_value(nil, config["min_divergence_threshold"], env, "KJ_MIN_DIVERGENCE_THRESHOLD").to_s,
1215
+ min_dev_ruby: minimum_dev_ruby_token(min_ruby),
1216
+ version: version.to_s,
1217
+ github_org: github_org_from_url(source_url).to_s
1218
+ )
1219
+ end
1220
+
1221
+ def project_runtime_template_tokens(project_runtime)
1222
+ {
1223
+ "KJ|FREEZE_TOKEN" => project_runtime[:freeze_token].to_s,
1224
+ "KJ|KETTLE_JEM_VERSION" => project_runtime[:kettle_jem_version].to_s,
1225
+ "KJ|TEMPLATE_RUN_DATE" => project_runtime[:template_run_date].to_s,
1226
+ "KJ|TEMPLATE_RUN_YEAR" => project_runtime[:template_run_year].to_s,
1227
+ "KJ|KETTLE_DEV_GEM" => project_runtime[:kettle_dev_gem].to_s,
1228
+ "KJ|YARD_HOST" => project_runtime[:yard_host].to_s,
1229
+ "KJ|PROJECT_EMOJI" => project_runtime[:project_emoji].to_s,
1230
+ "KJ|MIN_DIVERGENCE_THRESHOLD" => project_runtime[:min_divergence_threshold].to_s,
1231
+ }
1232
+ end
1233
+
1234
+ def shield_token(value)
1235
+ value.to_s.gsub("-", "--").gsub("_", "__").gsub("::", "%3A%3A").tr(" ", "_")
1236
+ end
1237
+
1238
+ def github_org_from_url(url)
1239
+ match = url.to_s.match(%r{\Ahttps?://github\.com/([^/]+)/})
1240
+ match && match[1]
1241
+ end
1242
+
1243
+ def concrete_github_url(url)
1244
+ github_org_from_url(url) ? url.to_s : nil
1245
+ end
1246
+
1247
+ def readme_logo_facts(config, package_name:, github_org:)
1248
+ entries = readme_top_logo_entries(readme_top_logo_mode(config), org: github_org.to_s, gem_name: package_name.to_s)
1249
+ compact_hash(
1250
+ top_logo_mode: readme_top_logo_mode(config),
1251
+ top_logo_row: [README_STATIC_TOP_LOGO_ROW, readme_top_logo_row(entries)].reject(&:empty?).join(" "),
1252
+ top_logo_refs: [README_STATIC_TOP_LOGO_REFS, readme_top_logo_refs(entries)].reject(&:empty?).join("\n")
1253
+ )
1254
+ end
1255
+
1256
+ def readme_top_logo_mode(config)
1257
+ raw_config = config.is_a?(Hash) ? config["readme"] : nil
1258
+ readme_config = raw_config.is_a?(Hash) ? raw_config : {}
1259
+ normalized = readme_config["top_logo_mode"].to_s.strip.downcase.tr("-", "_")
1260
+ return README_TOP_LOGO_MODE_DEFAULT if normalized.empty?
1261
+ return normalized if README_TOP_LOGO_MODES.include?(normalized)
1262
+
1263
+ README_TOP_LOGO_MODE_DEFAULT
1264
+ end
1265
+
1266
+ def readme_top_logo_entries(mode, org:, gem_name:)
1267
+ return [] if org.empty?
1268
+
1269
+ entries = []
1270
+ if mode == "org" || mode == "org_and_project"
1271
+ entries << {
1272
+ label: org,
1273
+ image_ref: "#{org}-i",
1274
+ link_ref: org,
1275
+ image_url: "https://logos.galtzo.com/assets/images/#{org}/avatar-192px.svg",
1276
+ href: "https://github.com/#{org}",
1277
+ }
1278
+ end
1279
+ if mode == "project" || mode == "org_and_project"
1280
+ entries << {
1281
+ label: gem_name,
1282
+ image_ref: "#{gem_name}-i",
1283
+ link_ref: gem_name,
1284
+ image_url: "https://logos.galtzo.com/assets/images/#{org}/#{gem_name}/avatar-192px.svg",
1285
+ href: "https://github.com/#{org}/#{gem_name}",
1286
+ }
1287
+ end
1288
+ entries.uniq { |entry| [entry[:image_ref], entry[:link_ref], entry[:image_url], entry[:href]] }
1289
+ end
1290
+
1291
+ def readme_top_logo_row(entries)
1292
+ entries.map do |entry|
1293
+ "[![#{entry[:label]} Logo by Aboling0, CC BY-SA 4.0][🖼️#{entry[:image_ref]}]][🖼️#{entry[:link_ref]}]"
1294
+ end.join(" ")
1295
+ end
1296
+
1297
+ def readme_top_logo_refs(entries)
1298
+ entries.flat_map do |entry|
1299
+ [
1300
+ "[🖼️#{entry[:image_ref]}]: #{entry[:image_url]}",
1301
+ "[🖼️#{entry[:link_ref]}]: #{entry[:href]}",
1302
+ ]
1303
+ end.join("\n")
1304
+ end
1305
+
1306
+ def readme_logo_template_tokens(readme_logo)
1307
+ {
1308
+ "KJ|README:TOP_LOGO_ROW" => readme_logo[:top_logo_row].to_s,
1309
+ "KJ|README:TOP_LOGO_REFS" => readme_logo[:top_logo_refs].to_s,
1310
+ }
1311
+ end
1312
+
1313
+ def rubocop_template_tokens(min_ruby)
1314
+ constraint, gem_name = rubocop_tokens_for(min_ruby_version(min_ruby))
1315
+ {
1316
+ "KJ|RUBOCOP_LTS_CONSTRAINT" => constraint,
1317
+ "KJ|RUBOCOP_RUBY_GEM" => gem_name,
1318
+ }
1319
+ end
1320
+
1321
+ def rubocop_tokens_for(min_ruby)
1322
+ fallback = RUBOCOP_VERSION_MAP.first
1323
+ selected = nil
1324
+ RUBOCOP_VERSION_MAP.reverse_each do |minimum, constraint|
1325
+ next unless min_ruby && min_ruby >= minimum
1326
+
1327
+ selected = [minimum, constraint]
1328
+ break
1329
+ end
1330
+ selected ||= fallback
1331
+ [selected[1], "rubocop-ruby#{selected[0].segments.join("_")}"]
1332
+ end
1333
+
1334
+ def min_ruby_version(requirement)
1335
+ token = minimum_ruby_token(requirement)
1336
+ return nil if token.empty?
1337
+
1338
+ Gem::Version.new(token)
1339
+ rescue ArgumentError
1340
+ nil
1341
+ end
1342
+
1343
+ def license_facts(config, gemspec_licenses, author_email: nil)
1344
+ licenses = resolved_licenses(config, gemspec_licenses)
1345
+ primary = licenses.first
1346
+ compat_category = license_compat_category(licenses)
1347
+ compact_hash(
1348
+ spdx: licenses,
1349
+ expression: licenses.join(" OR "),
1350
+ primary_spdx: primary,
1351
+ license_md_content: license_md_content(licenses, author_email: author_email),
1352
+ readme_license_intro: readme_license_intro(licenses, author_email: author_email),
1353
+ readme_license_badge: license_badge(primary),
1354
+ readme_license_compat_badge: license_compat_badge(compat_category),
1355
+ readme_license_refs: readme_license_refs(primary, compat_category),
1356
+ copyright_prefix: polyform_licenses?(licenses) ? "Required Notice: " : ""
1357
+ )
1358
+ end
1359
+
1360
+ def resolved_licenses(config, gemspec_licenses)
1361
+ config_licenses = config.is_a?(Hash) ? config["licenses"] : nil
1362
+ licenses = Array(config_licenses).map { |license| license.to_s.strip }.reject(&:empty?)
1363
+ return licenses unless licenses.empty?
1364
+
1365
+ licenses = Array(gemspec_licenses).map { |license| license.to_s.strip }.reject(&:empty?)
1366
+ licenses.empty? ? ["MIT"] : licenses
1367
+ end
1368
+
1369
+ def license_template_tokens(license)
1370
+ {
1371
+ "KJ|LICENSE_MD_CONTENT" => license[:license_md_content].to_s,
1372
+ "KJ|README:LICENSE_INTRO" => license[:readme_license_intro].to_s,
1373
+ "KJ|LICENSE:PRIMARY_SPDX" => license[:primary_spdx].to_s,
1374
+ "KJ|README:LICENSE_BADGE" => license[:readme_license_badge].to_s,
1375
+ "KJ|README:LICENSE_COMPAT_BADGE" => license[:readme_license_compat_badge].to_s,
1376
+ "KJ|README:LICENSE_REFS" => license[:readme_license_refs].to_s,
1377
+ "KJ|COPYRIGHT_PREFIX" => license[:copyright_prefix].to_s,
1378
+ }
1379
+ end
1380
+
1381
+ def license_md_content(licenses, author_email: nil)
1382
+ content = <<~MARKDOWN.chomp
1383
+ # License
1384
+
1385
+ This project is made available under the following license#{"s" if licenses.size > 1}.
1386
+ Choose the option that best fits your use case:
1387
+
1388
+ #{licenses.map { |license| "- #{license_link(license)}" }.join("\n")}
1389
+ MARKDOWN
1390
+ guide_table = license_use_case_guide_table(licenses, author_email: author_email)
1391
+ content += "\n\n## Use-case guide\n\n#{guide_table}" if guide_table
1392
+ content += "\n\n#{license_contact_line(author_email, context: :license_md)}" if non_mit_licenses?(licenses)
1393
+ content
1394
+ end
1395
+
1396
+ def readme_license_intro(licenses, author_email: nil)
1397
+ return mit_readme_license_intro if licenses == ["MIT"]
1398
+
1399
+ intro = "The gem is available under the following license#{"s" if licenses.size > 1}: " \
1400
+ "#{licenses.map { |license| license_link(license) }.join(", ")}.\n" \
1401
+ "See [LICENSE.md][#{paperclip_ref(:license)}] for details."
1402
+ intro += "\n\n#{license_contact_line(author_email, context: :readme)}" if non_mit_licenses?(licenses)
1403
+ guide_table = license_use_case_guide_table(licenses, author_email: author_email)
1404
+ intro += "\n\n### License use-case guide\n\n#{guide_table}" if guide_table
1405
+ intro
1406
+ end
1407
+
1408
+ def mit_readme_license_intro
1409
+ "The gem is available as open source under the terms of\n" \
1410
+ "the #{license_link("MIT")} #{license_badge("MIT")}."
1411
+ end
1412
+
1413
+ def license_contact_line(author_email, context:)
1414
+ if author_email.to_s.empty?
1415
+ return "If none of the above licenses fit your use case, please contact the project maintainer to discuss a custom commercial license." if context == :license_md
1416
+
1417
+ "If none of the available licenses suit your use case, please contact the project maintainer to discuss a custom commercial license."
1418
+ elsif context == :license_md
1419
+ "If none of the above licenses fit your use case, please [contact us](mailto:#{author_email}) to discuss a custom commercial license."
1420
+ else
1421
+ "If none of the available licenses suit your use case, please [contact us](mailto:#{author_email}) to discuss a custom commercial license."
1422
+ end
1423
+ end
1424
+
1425
+ def readme_license_refs(primary, compat_category)
1426
+ [
1427
+ "[#{paperclip_ref(:copyright_notice_explainer)}]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year",
1428
+ "[#{paperclip_ref(:license)}]: LICENSE.md",
1429
+ "[#{paperclip_ref(:license_ref)}]: #{license_badge_ref(primary)}",
1430
+ "[#{paperclip_ref(:license_img)}]: #{license_badge_img(primary)}",
1431
+ "[#{paperclip_ref(:license_compat)}]: #{license_compat_ref(compat_category)}",
1432
+ "[#{paperclip_ref(:license_compat_img)}]: #{license_compat_img(compat_category)}",
1433
+ ].join("\n")
1434
+ end
1435
+
1436
+ def spdx_basename(spdx_id)
1437
+ spdx_id.to_s.sub(/\ALicenseRef-/, "")
1438
+ end
1439
+
1440
+ def license_link(spdx_id)
1441
+ base = spdx_basename(spdx_id)
1442
+ "[#{base}](#{base}.md)"
1443
+ end
1444
+
1445
+ def license_badge(spdx_id)
1446
+ base = spdx_basename(spdx_id)
1447
+ "[![License: #{base}][#{paperclip_ref(:license_img)}]][#{paperclip_ref(:license_ref)}]"
1448
+ end
1449
+
1450
+ def license_badge_ref(spdx_id)
1451
+ "#{spdx_basename(spdx_id)}.md"
1452
+ end
1453
+
1454
+ def license_badge_img(spdx_id)
1455
+ base = spdx_basename(spdx_id).gsub("-", "--").gsub("_", "__").tr(" ", "_")
1456
+ "https://img.shields.io/badge/License-#{base}-259D6C.svg"
1457
+ end
1458
+
1459
+ def license_compat_category(licenses)
1460
+ categories = Array(licenses).filter_map { |license| APACHE_LICENSE_COMPAT_CATEGORIES[license.to_s] }.uniq
1461
+ return :a if categories.include?(:a)
1462
+ return :b if categories.include?(:b)
1463
+ return :x if categories.any? && categories.all?(:x)
1464
+
1465
+ :unknown
1466
+ end
1467
+
1468
+ def license_compat_badge(category)
1469
+ data = APACHE_LICENSE_COMPAT_BADGE_DATA.fetch(category)
1470
+ "[![#{data.fetch(:alt)}][#{paperclip_ref(:license_compat_img)}]][#{paperclip_ref(:license_compat)}]"
1471
+ end
1472
+
1473
+ def license_compat_ref(category)
1474
+ APACHE_LICENSE_COMPAT_BADGE_DATA.fetch(category).fetch(:ref)
1475
+ end
1476
+
1477
+ def license_compat_img(category)
1478
+ data = APACHE_LICENSE_COMPAT_BADGE_DATA.fetch(category)
1479
+ "https://img.shields.io/badge/#{data.fetch(:label)}-#{data.fetch(:message)}-#{data.fetch(:color)}.svg?style=flat&logo=Apache"
1480
+ end
1481
+
1482
+ def polyform_licenses?(licenses)
1483
+ licenses.any? { |license| license.to_s.start_with?("PolyForm-") }
1484
+ end
1485
+
1486
+ def non_mit_licenses?(licenses)
1487
+ licenses.any? { |license| license != "MIT" }
1488
+ end
1489
+
1490
+ def license_use_case_guide_table(licenses, author_email: nil)
1491
+ has_floss_oss = licenses.include?("MIT") || licenses.include?("AGPL-3.0-only")
1492
+ has_polyform = licenses.include?("PolyForm-Noncommercial-1.0.0") || licenses.include?("PolyForm-Small-Business-1.0.0")
1493
+ has_big_time = licenses.include?("LicenseRef-Big-Time-Public-License")
1494
+ return unless has_floss_oss && has_polyform && has_big_time
1495
+
1496
+ rows = license_use_case_rows(licenses, author_email: author_email)
1497
+ return if rows.empty?
1498
+
1499
+ "| Use case | License |\n|---|---|\n" +
1500
+ rows.map { |use_case, license| "| #{use_case} | #{license} |" }.join("\n")
1501
+ end
1502
+
1503
+ def license_use_case_rows(licenses, author_email: nil)
1504
+ rows = []
1505
+ rows << ["FLOSS (free and open source)", license_link("MIT")] if licenses.include?("MIT")
1506
+ rows << ["Copy-left open source", license_link("AGPL-3.0-only")] if licenses.include?("AGPL-3.0-only")
1507
+ noncommercial_links = %w[PolyForm-Noncommercial-1.0.0 PolyForm-Small-Business-1.0.0 LicenseRef-Big-Time-Public-License]
1508
+ .select { |license| licenses.include?(license) }
1509
+ .map { |license| license_link(license) }
1510
+ rows << ["Non-commercial (research, education, personal use)", noncommercial_links.join(" or ")] unless noncommercial_links.empty?
1511
+ small_business_links = %w[PolyForm-Small-Business-1.0.0 LicenseRef-Big-Time-Public-License]
1512
+ .select { |license| licenses.include?(license) }
1513
+ .map { |license| license_link(license) }
1514
+ rows << ["Small business commercial", small_business_links.join(" or ")] unless small_business_links.empty?
1515
+ rows << ["Larger business commercial", large_business_license_cell(author_email)] if licenses.include?("LicenseRef-Big-Time-Public-License")
1516
+ rows
1517
+ end
1518
+
1519
+ def large_business_license_cell(author_email)
1520
+ cell = license_link("LicenseRef-Big-Time-Public-License")
1521
+ if author_email.to_s.empty?
1522
+ "#{cell} or contact us for a custom license"
1523
+ else
1524
+ "#{cell} or [contact us](mailto:#{author_email}) for a custom license"
1525
+ end
1526
+ end
1527
+
1528
+ def paperclip_ref(name)
1529
+ {
1530
+ copyright_notice_explainer: "\u{1F4C4}copyright-notice-explainer",
1531
+ license: "\u{1F4C4}license",
1532
+ license_ref: "\u{1F4C4}license-ref",
1533
+ license_img: "\u{1F4C4}license-img",
1534
+ license_compat: "\u{1F4C4}license-compat",
1535
+ license_compat_img: "\u{1F4C4}license-compat-img",
1536
+ }.fetch(name)
1537
+ end
1538
+
1539
+ def author_given_names(name)
1540
+ parts = name.to_s.strip.split(/\s+/)
1541
+ return "" if parts.size < 2
1542
+
1543
+ parts[0...-1].join(" ")
1544
+ end
1545
+
1546
+ def author_family_names(name)
1547
+ parts = name.to_s.strip.split(/\s+/)
1548
+ return "" if parts.size < 2
1549
+
1550
+ parts[-1]
1551
+ end
1552
+
1553
+ def resolve_template_tokens(content, tokens, scan_unresolved: true)
1554
+ resolver = Token::Resolver::Resolve.new(on_missing: :keep)
1555
+ document = Token::Resolver::Document.new(content.to_s, config: TEMPLATE_TOKEN_CONFIG)
1556
+ resolved = resolver.resolve(document, stringify_template_tokens(tokens))
1557
+ return resolved unless scan_unresolved
1558
+
1559
+ unresolved = Token::Resolver::Document.new(resolved, config: TEMPLATE_TOKEN_CONFIG).token_keys.grep(/\AKJ\|/).sort
1560
+ return resolved if unresolved.empty?
1561
+
1562
+ raise ArgumentError, "unresolved kettle-jem template tokens: #{unresolved.map { |token| "{#{token}}" }.join(", ")}"
1563
+ end
1564
+
1565
+ def unresolved_template_scan?(recipe)
1566
+ return false if recipe.fetch(:target_path).to_s == ".kettle-jem.yml"
1567
+ return false if recipe.dig(:template_preference, :skip_unresolved_scan)
1568
+
1569
+ true
1570
+ end
1571
+
1572
+ def stringify_template_tokens(tokens)
1573
+ tokens.to_h.transform_keys(&:to_s).transform_values(&:to_s)
1574
+ end
1575
+
1576
+ def falsey_config?(value)
1577
+ %w[false no 0].include?(value.to_s.strip.downcase)
1578
+ end
1579
+
1580
+ def merge_readme_template(template_content:, destination_content:, preserve_config: {})
1581
+ return template_content if destination_content.to_s.strip.empty?
1582
+
1583
+ preserved = preserve_readme_sections(template_content, destination_content, preserve_config)
1584
+ preserve_readme_h1(preserved, destination_content)
1585
+ end
1586
+
1587
+ def preserve_readme_sections(template_content, destination_content, preserve_config)
1588
+ template_sections = markdown_sections(template_content)
1589
+ destination_sections = markdown_sections(destination_content)
1590
+ destination_lookup = destination_sections.to_h { |section| [section.fetch(:base), section] }
1591
+ preserve_targets = readme_preserve_targets(template_sections, destination_lookup, preserve_config)
1592
+ return template_content if preserve_targets.empty?
1593
+
1594
+ lines = template_content.split("\n", -1)
1595
+ template_sections.reverse_each do |section|
1596
+ next unless preserve_targets.include?(section.fetch(:base))
1597
+
1598
+ destination_section = destination_lookup[section.fetch(:base)] ||
1599
+ aliased_readme_destination_section(section.fetch(:base), destination_lookup, preserve_config)
1600
+ next unless destination_section
1601
+
1602
+ replacement = "#{section.fetch(:heading)}\n#{destination_section.fetch(:body)}".split("\n", -1)
1603
+ lines[section.fetch(:start)..section.fetch(:end)] = replacement
1604
+ end
1605
+ lines.join("\n")
1606
+ end
1607
+
1608
+ def preserve_readme_h1(merged_content, destination_content)
1609
+ merged_h1 = markdown_sections(merged_content).find { |section| section.fetch(:level) == 1 }
1610
+ destination_h1 = markdown_sections(destination_content).find { |section| section.fetch(:level) == 1 }
1611
+ return merged_content unless merged_h1 && destination_h1
1612
+ return merged_content if semantic_readme_heading(destination_h1.fetch(:heading_text)) == semantic_readme_heading(merged_h1.fetch(:heading_text))
1613
+
1614
+ lines = merged_content.split("\n", -1)
1615
+ lines[merged_h1.fetch(:start)] = destination_h1.fetch(:heading)
1616
+ lines.join("\n")
1617
+ end
1618
+
1619
+ def markdown_sections(content)
1620
+ lines = content.to_s.split("\n", -1)
1621
+ headings = []
1622
+ in_fence = false
1623
+ fence_marker = nil
1624
+ lines.each_with_index do |line, index|
1625
+ stripped = line.lstrip
1626
+ if in_fence
1627
+ if stripped.match?(/\A#{Regexp.escape(fence_marker)}\s*\z/)
1628
+ in_fence = false
1629
+ fence_marker = nil
1630
+ end
1631
+ next
1632
+ end
1633
+ if (fence = stripped.match(/\A(`{3,}|~{3,})/))
1634
+ in_fence = true
1635
+ fence_marker = fence[1]
1636
+ next
1637
+ end
1638
+ next unless (heading = line.match(/\A(\#{1,6})\s+(.+?)\s*#*\s*\z/))
1639
+
1640
+ headings << {
1641
+ start: index,
1642
+ level: heading[1].length,
1643
+ heading: line,
1644
+ heading_text: heading[2],
1645
+ base: normalize_readme_heading(heading[2]),
1646
+ }
1647
+ end
1648
+
1649
+ headings.each_with_index.map do |heading, index|
1650
+ following = headings[(index + 1)..].to_a.find { |candidate| candidate.fetch(:level) <= heading.fetch(:level) }
1651
+ branch_end = following ? following.fetch(:start) - 1 : lines.length - 1
1652
+ body = (lines[(heading.fetch(:start) + 1)..branch_end] || []).join("\n")
1653
+ heading.merge(end: branch_end, body: body)
1654
+ end
1655
+ end
1656
+
1657
+ def readme_preserve_targets(template_sections, destination_lookup, preserve_config)
1658
+ sections = Array(preserve_config[:sections]).map { |section| normalize_readme_heading(section) }
1659
+ sections = README_DEFAULT_PRESERVE_SECTIONS.dup if sections.empty?
1660
+ patterns = Array(preserve_config[:patterns]).map { |pattern| pattern.to_s.strip.downcase }
1661
+ patterns = README_DEFAULT_PRESERVE_PATTERNS.dup if patterns.empty?
1662
+ aliases = preserve_config[:aliases] || README_SECTION_ALIASES
1663
+ targets = sections.dup
1664
+ template_sections.each do |section|
1665
+ base = section.fetch(:base)
1666
+ targets << base if patterns.any? { |pattern| File.fnmatch?(pattern, base, File::FNM_PATHNAME) }
1667
+ end
1668
+ aliases.each do |from, to|
1669
+ targets << to if destination_lookup.key?(from) && targets.include?(to)
1670
+ end
1671
+ targets.uniq
1672
+ end
1673
+
1674
+ def aliased_readme_destination_section(template_base, destination_lookup, preserve_config)
1675
+ aliases = preserve_config[:aliases] || README_SECTION_ALIASES
1676
+ aliases.each do |from, to|
1677
+ return destination_lookup[from] if to == template_base && destination_lookup.key?(from)
1678
+ end
1679
+ nil
1680
+ end
1681
+
1682
+ def readme_preserve_config(config)
1683
+ readme = config["readme"]
1684
+ return {} unless readme.is_a?(Hash)
1685
+
1686
+ result = {}
1687
+ result[:sections] = Array(readme["preserve_sections"]) if readme.key?("preserve_sections")
1688
+ result[:patterns] = Array(readme["preserve_patterns"]) if readme.key?("preserve_patterns")
1689
+ if readme["section_aliases"].is_a?(Hash)
1690
+ result[:aliases] = README_SECTION_ALIASES.merge(
1691
+ readme["section_aliases"].transform_keys { |key| normalize_readme_heading(key) }
1692
+ .transform_values { |value| normalize_readme_heading(value) }
1693
+ )
1694
+ end
1695
+ result
1696
+ end
1697
+
1698
+ def normalize_readme_heading(text)
1699
+ strip_readme_heading_adornment(text).strip.downcase
1700
+ end
1701
+
1702
+ def semantic_readme_heading(text)
1703
+ normalize_readme_heading(text)
1704
+ end
1705
+
1706
+ def strip_readme_heading_adornment(text)
1707
+ text.to_s.sub(/\A(?:\d\uFE0F?\u20E3|[^[:alnum:][:space:]])+[ \t]*/u, "")
1708
+ end
1709
+
1710
+ def template_source_preferences(project_root, config, opencollective_disabled: false)
1711
+ templates = config["templates"]
1712
+ return [] unless templates.is_a?(Hash)
1713
+
1714
+ root = template_root(project_root, templates)
1715
+ entries = template_entries(project_root, root, templates)
1716
+ return [] if entries.empty?
1717
+
1718
+ apply_templates = templates["apply"] == true
1719
+ entries.filter_map do |entry|
1720
+ template_source_preference(
1721
+ project_root,
1722
+ root,
1723
+ entry,
1724
+ config,
1725
+ opencollective_disabled: opencollective_disabled,
1726
+ apply_templates: apply_templates
1727
+ )
1728
+ end
1729
+ end
1730
+
1731
+ def template_entries(project_root, root, templates)
1732
+ return templates["entries"] if templates["entries"].is_a?(Array)
1733
+ return [] if templates.key?("entries")
1734
+
1735
+ template_inventory_entries(project_root, root.fetch(:path))
1736
+ end
1737
+
1738
+ def template_inventory_entries(project_root, template_root_path)
1739
+ logical_paths = []
1740
+ Find.find(template_root_path) do |path|
1741
+ next if File.directory?(path)
1742
+
1743
+ relative_path = path.delete_prefix("#{template_root_path}/")
1744
+ logical_path = relative_path
1745
+ .sub(/\.no-osc\.example\z/, "")
1746
+ .sub(/\.example\z/, "")
1747
+ logical_paths << logical_path unless logical_path.empty?
1748
+ end
1749
+
1750
+ logical_paths.uniq.sort.map do |logical_path|
1751
+ target_path = template_inventory_target_path(project_root, logical_path)
1752
+ if target_path == logical_path
1753
+ logical_path
1754
+ else
1755
+ { "source" => logical_path, "target" => target_path }
1756
+ end
1757
+ end
1758
+ end
1759
+
1760
+ def template_inventory_target_path(project_root, logical_path)
1761
+ return ".env.local.example" if logical_path == ".env.local"
1762
+
1763
+ if logical_path.end_with?(".gemspec")
1764
+ existing_gemspec = Dir.glob(File.join(project_root, "*.gemspec")).sort.first
1765
+ return File.basename(existing_gemspec) if existing_gemspec
1766
+ end
1767
+
1768
+ logical_path
1769
+ end
1770
+
1771
+ def kettle_config_bootstrap_facts(project_root, env)
1772
+ return if File.exist?(File.join(project_root, ".kettle-jem.yml"))
1773
+
1774
+ selected_source = preferred_template_source(PACKAGED_TEMPLATE_ROOT, ".kettle-jem.yml")
1775
+ return unless selected_source
1776
+
1777
+ {
1778
+ template_preference: {
1779
+ target_path: ".kettle-jem.yml",
1780
+ configured_source: ".kettle-jem.yml",
1781
+ selected_source: selected_source,
1782
+ source_relative_path: selected_source,
1783
+ source_root: "packaged",
1784
+ source_root_path: PACKAGED_TEMPLATE_ROOT,
1785
+ selection_reason: template_source_selection_reason(".kettle-jem.yml", selected_source),
1786
+ apply: true,
1787
+ },
1788
+ min_divergence_threshold: preferred_template_token_value(nil, nil, env, "KJ_MIN_DIVERGENCE_THRESHOLD").to_s,
1789
+ }
1790
+ end
1791
+
1792
+ def kettle_config_bootstrap_recipe(bootstrap)
1793
+ recipe = recipe_entry(
1794
+ "kettle_config_bootstrap",
1795
+ ".kettle-jem.yml",
1796
+ "yaml",
1797
+ "supplied_kettle_config_bootstrap",
1798
+ facts: %w[kettle_config_bootstrap]
1799
+ )
1800
+ recipe[:template_preference] = bootstrap.fetch(:template_preference)
1801
+ recipe[:template_tokens] = {
1802
+ "KJ|MIN_DIVERGENCE_THRESHOLD" => bootstrap.fetch(:min_divergence_threshold).to_s,
1803
+ }
1804
+ recipe
1805
+ end
1806
+
1807
+ def template_source_preference(project_root, template_root, entry, config, opencollective_disabled: false, apply_templates: false)
1808
+ source_path, target_path = template_entry_paths(entry)
1809
+ return nil if source_path.to_s.empty? || target_path.to_s.empty?
1810
+
1811
+ selected_source = preferred_template_source(template_root.fetch(:path), source_path, opencollective_disabled: opencollective_disabled)
1812
+ return nil unless selected_source
1813
+
1814
+ strategy_config = template_strategy_config(config, target_path)
1815
+ preference = {
1816
+ target_path: target_path,
1817
+ configured_source: source_path,
1818
+ selected_source: template_source_display_path(template_root, selected_source),
1819
+ selection_reason: template_source_selection_reason(source_path, template_source_display_path(template_root, selected_source)),
1820
+ apply: template_entry_apply?(entry, apply_templates),
1821
+ }
1822
+ preference[:strategy] = strategy_config.fetch(:strategy).to_s if strategy_config
1823
+ preference[:file_type] = strategy_config.fetch(:file_type).to_s if strategy_config&.key?(:file_type)
1824
+ preserve_config = readme_preserve_config(config)
1825
+ preference[:readme_preserve_config] = preserve_config if target_path == "README.md" && !preserve_config.empty?
1826
+ if template_root.fetch(:kind) == "packaged"
1827
+ preference[:source_relative_path] = selected_source
1828
+ preference[:source_root] = template_root.fetch(:kind)
1829
+ preference[:source_root_path] = template_root.fetch(:path)
1830
+ end
1831
+ preference
1832
+ end
1833
+
1834
+ def template_strategy_config(config, target_path)
1835
+ template_file_strategy_config(config, target_path) || template_pattern_strategy_config(config, target_path)
1836
+ end
1837
+
1838
+ def template_file_strategy_config(config, target_path)
1839
+ files = config["files"]
1840
+ return unless files.is_a?(Hash)
1841
+
1842
+ current = files
1843
+ target_path.to_s.delete_prefix("./").split("/").each do |part|
1844
+ return unless current.is_a?(Hash) && current.key?(part)
1845
+
1846
+ current = current[part]
1847
+ end
1848
+ return unless current.is_a?(Hash) && current.key?("strategy")
1849
+
1850
+ template_strategy_entry(config, nil, current)
1851
+ end
1852
+
1853
+ def template_pattern_strategy_config(config, target_path)
1854
+ patterns = config["patterns"]
1855
+ return unless patterns.is_a?(Array)
1856
+
1857
+ match = patterns.find do |entry|
1858
+ entry.is_a?(Hash) &&
1859
+ File.fnmatch?(entry["path"].to_s, target_path.to_s, File::FNM_PATHNAME | File::FNM_EXTGLOB | File::FNM_DOTMATCH)
1860
+ end
1861
+ return unless match
1862
+
1863
+ template_strategy_entry(config, match["path"].to_s, match)
1864
+ end
1865
+
1866
+ def template_strategy_entry(config, path, entry)
1867
+ strategy = entry["strategy"].to_s.strip.downcase.to_sym
1868
+ raise ArgumentError, "unknown kettle-jem template strategy: #{entry["strategy"]}" unless SUPPORTED_TEMPLATE_STRATEGIES.include?(strategy)
1869
+
1870
+ result = { strategy: strategy }
1871
+ result[:path] = path if path
1872
+ result[:skip_unresolved_scan] = true if entry["skip_unresolved_scan"]
1873
+ if entry.key?("file_type")
1874
+ file_type = entry["file_type"].to_s.strip.downcase.tr("-", "_").to_sym
1875
+ raise ArgumentError, "unknown kettle-jem template file_type: #{entry["file_type"]}" unless SUPPORTED_TEMPLATE_FILE_TYPES.include?(file_type)
1876
+
1877
+ result[:file_type] = file_type
1878
+ end
1879
+ if strategy == :merge
1880
+ defaults = config["defaults"].is_a?(Hash) ? config["defaults"] : {}
1881
+ result[:preference] = (entry.key?("preference") ? entry["preference"] : defaults["preference"]).to_s if entry.key?("preference") || defaults.key?("preference")
1882
+ if entry.key?("add_template_only_nodes") || defaults.key?("add_template_only_nodes")
1883
+ result[:add_template_only_nodes] = entry.key?("add_template_only_nodes") ? entry["add_template_only_nodes"] : defaults["add_template_only_nodes"]
1884
+ end
1885
+ result[:freeze_token] = (entry.key?("freeze_token") ? entry["freeze_token"] : defaults["freeze_token"]).to_s if entry.key?("freeze_token") || defaults.key?("freeze_token")
1886
+ end
1887
+ result
1888
+ end
1889
+
1890
+ def template_root(project_root, templates)
1891
+ configured_root = templates["root"].to_s
1892
+ if configured_root.empty?
1893
+ local_root = File.join(project_root, "template")
1894
+ return { kind: "project", path: local_root, display_prefix: "template" } if Dir.exist?(local_root)
1895
+
1896
+ return { kind: "packaged", path: PACKAGED_TEMPLATE_ROOT }
1897
+ end
1898
+
1899
+ return { kind: "packaged", path: PACKAGED_TEMPLATE_ROOT } if configured_root == "packaged"
1900
+
1901
+ path = configured_root.start_with?("/") ? configured_root : File.join(project_root, configured_root)
1902
+ { kind: "project", path: path, display_prefix: configured_root }
1903
+ end
1904
+
1905
+ def template_source_display_path(template_root, selected_source)
1906
+ prefix = template_root[:display_prefix].to_s
1907
+ return selected_source if prefix.empty?
1908
+
1909
+ File.join(prefix, selected_source)
1910
+ end
1911
+
1912
+ def template_entry_paths(entry)
1913
+ if entry.is_a?(Hash)
1914
+ source_path = entry.fetch("source", entry["target"]).to_s
1915
+ target_path = entry.fetch("target", source_path.sub(/\.example\z/, "")).to_s
1916
+ [source_path, target_path]
1917
+ else
1918
+ source_path = entry.to_s
1919
+ [source_path, source_path.sub(/\.example\z/, "")]
1920
+ end
1921
+ end
1922
+
1923
+ def template_entry_apply?(entry, apply_templates)
1924
+ return entry["apply"] == true if entry.is_a?(Hash) && entry.key?("apply")
1925
+
1926
+ apply_templates
1927
+ end
1928
+
1929
+ def preferred_template_source(template_root, configured_source, opencollective_disabled: false)
1930
+ base = configured_source.sub(/\.example\z/, "")
1931
+ candidates = []
1932
+ candidates << "#{base}.no-osc.example" if opencollective_disabled
1933
+ candidates << "#{base}.example"
1934
+ candidates << configured_source
1935
+ candidates.find { |relative_path| File.exist?(File.join(template_root, relative_path)) }
1936
+ end
1937
+
1938
+ def template_source_selection_reason(configured_source, selected_source)
1939
+ if selected_source.end_with?(".no-osc.example")
1940
+ "opencollective_disabled_no_osc_variant"
1941
+ elsif selected_source.end_with?(".example")
1942
+ "default_example_variant"
1943
+ elsif selected_source == configured_source
1944
+ "configured_source"
1945
+ else
1946
+ "fallback_source"
1947
+ end
1948
+ end
1949
+
1950
+ def github_actions_framework_matrix(config)
1951
+ workflows = config["workflows"]
1952
+ return {} unless workflows.is_a?(Hash) && workflows["preset"].to_s.strip.downcase == "framework"
1953
+
1954
+ raw = workflows["framework_matrix"]
1955
+ return {} unless raw.is_a?(Hash)
1956
+
1957
+ dimension = raw["dimension"].to_s.strip
1958
+ versions = raw["versions"]
1959
+ pattern = raw["gemfile_pattern"].to_s.strip
1960
+ return {} unless !dimension.empty? && versions.is_a?(Array) && !versions.empty? && !pattern.empty?
1961
+
1962
+ normalized_versions = versions.map { |version| version.to_s.strip }.reject(&:empty?)
1963
+ return {} if normalized_versions.empty?
1964
+
1965
+ {
1966
+ dimension: dimension,
1967
+ versions: normalized_versions,
1968
+ gemfile_pattern: pattern,
1969
+ include: normalized_versions.map do |version|
1970
+ gemfile = expand_framework_gemfile_pattern(pattern, version)
1971
+ { framework_version: version, gemfile: framework_gemfile_path(gemfile) }
1972
+ end,
1973
+ }
1974
+ end
1975
+
1976
+ def github_actions_coverage_config(config)
1977
+ workflows = config["workflows"]
1978
+ return {} unless workflows.is_a?(Hash)
1979
+
1980
+ raw = workflows["coverage"]
1981
+ enabled = raw == true || (raw.is_a?(Hash) && raw.fetch("enabled", false) == true)
1982
+ return {} unless enabled
1983
+
1984
+ raw = {} unless raw.is_a?(Hash)
1985
+ {
1986
+ enabled: true,
1987
+ command: raw.fetch("command", "rake test").to_s,
1988
+ appraisal: raw.fetch("appraisal", "coverage").to_s,
1989
+ }
1990
+ end
1991
+
1992
+ def expand_framework_gemfile_pattern(pattern, version)
1993
+ replacement = if pattern.include?("_{version}") || pattern.include?("{version}_")
1994
+ version.tr(".", "_")
1995
+ else
1996
+ version
1997
+ end
1998
+ pattern.gsub("{version}", replacement)
1999
+ end
2000
+
2001
+ def framework_gemfile_path(gemfile)
2002
+ gemfile.include?("/") ? gemfile : "gemfiles/#{gemfile}"
2003
+ end
2004
+
2005
+ def classify_namespace(name)
2006
+ name.to_s.split(/[-_]/).map { |part| part[0].to_s.upcase + part[1..].to_s }.join("::")
2007
+ end
2008
+
2009
+ def readme_metadata_block(facts)
2010
+ package = facts.fetch(:package)
2011
+ funding_urls = facts.fetch(:funding, {}).fetch(:urls, [])
2012
+ rows = [
2013
+ ["Package", package[:name]],
2014
+ ["Description", package[:description]],
2015
+ ["Homepage", package[:homepage_url]],
2016
+ ["Source", package[:source_url]],
2017
+ ["License", package[:license_expression]],
2018
+ ["Funding", funding_urls.join(", ")],
2019
+ ].reject { |(_, value)| value.to_s.empty? }
2020
+
2021
+ [
2022
+ "<!-- kettle-jem:metadata:start -->",
2023
+ "| Field | Value |",
2024
+ "|---|---|",
2025
+ *rows.map { |field, value| "| #{field} | #{value} |" },
2026
+ "<!-- kettle-jem:metadata:end -->",
2027
+ ].join("\n")
2028
+ end
2029
+
2030
+ def synchronize_github_funding_yml(content, facts)
2031
+ funding = YAML.safe_load(content.to_s, permitted_classes: [], aliases: false) || {}
2032
+ funding = {} unless funding.is_a?(Hash)
2033
+ funding = funding.each_with_object({}) do |(key, value), memo|
2034
+ next if value.nil? || (value.respond_to?(:empty?) && value.empty?)
2035
+
2036
+ memo[key.to_s] = value
2037
+ end
2038
+ funding.delete("open_collective") if facts.fetch(:funding, {})[:open_collective_disabled]
2039
+ funding["tidelift"] ||= "rubygems/#{facts.fetch(:package).fetch(:name)}"
2040
+ YAML.dump(funding).sub(/\A---\n?/, "")
2041
+ end
2042
+
2043
+ def delete_rakefile_scaffold(content)
2044
+ selectors = rakefile_scaffold_delete_selectors(content)
2045
+ {
2046
+ content: delete_line_ranges(content.to_s, selectors),
2047
+ delete_selectors: selectors,
2048
+ }
2049
+ end
2050
+
2051
+ def rakefile_scaffold_delete_selectors(content)
2052
+ lines = content.to_s.lines
2053
+ selectors = []
2054
+ lines.each_with_index do |line, index|
2055
+ case line
2056
+ when /\A\s*require\s+["']bundler\/gem_tasks["']\s*(?:#.*)?\n?\z/
2057
+ selectors << rakefile_selector(
2058
+ "rakefile_scaffold_require_bundler_gem_tasks",
2059
+ index + 1,
2060
+ index + 1,
2061
+ "wrapper_selected_scaffold_require"
2062
+ )
2063
+ when /\A\s*require\s+["']rspec\/core\/rake_task["']\s*(?:#.*)?\n?\z/
2064
+ selectors << rakefile_selector(
2065
+ "rakefile_scaffold_require_rspec_core_rake_task",
2066
+ index + 1,
2067
+ index + 1,
2068
+ "wrapper_selected_scaffold_require"
2069
+ )
2070
+ when /\A\s*require\s+["']rubocop\/rake_task["']\s*(?:#.*)?\n?\z/
2071
+ selectors << rakefile_selector(
2072
+ "rakefile_scaffold_require_rubocop_rake_task",
2073
+ index + 1,
2074
+ index + 1,
2075
+ "wrapper_selected_scaffold_require"
2076
+ )
2077
+ when /\A\s*RSpec::Core::RakeTask\.new\b/
2078
+ selectors << rakefile_selector("rakefile_scaffold_rspec_task", index + 1, index + 1,
2079
+ "wrapper_selected_scaffold_task")
2080
+ when /\A\s*RuboCop::RakeTask\.new\b/
2081
+ selectors << rakefile_selector("rakefile_scaffold_rubocop_task", index + 1, index + 1,
2082
+ "wrapper_selected_scaffold_task")
2083
+ end
2084
+ end
2085
+ selectors.concat(rakefile_task_block_selectors(lines))
2086
+ selectors.sort_by { |selector| [selector.fetch(:start_line), selector.fetch(:end_line)] }
2087
+ end
2088
+
2089
+ def rakefile_task_block_selectors(lines)
2090
+ selectors = []
2091
+ index = 0
2092
+ while index < lines.length
2093
+ line = lines[index]
2094
+ if line.match?(/\A\s*task\s+default:/) || line.match?(/\A\s*task\s+:default\b/)
2095
+ unless rakefile_template_default_task?(lines, index)
2096
+ end_index = rakefile_block_end(lines, index)
2097
+ selectors << rakefile_selector("rakefile_scaffold_task_default", index + 1, end_index + 1,
2098
+ "wrapper_selected_scaffold_task")
2099
+ index = end_index + 1
2100
+ next
2101
+ end
2102
+ end
2103
+ index += 1
2104
+ end
2105
+ selectors
2106
+ end
2107
+
2108
+ def rakefile_template_default_task?(lines, task_index)
2109
+ cursor = task_index - 1
2110
+ cursor -= 1 while cursor >= 0 && lines[cursor].strip.empty?
2111
+ return false unless cursor >= 0
2112
+
2113
+ lines[cursor].strip == 'desc "Default tasks aggregator"'
2114
+ end
2115
+
2116
+ def rakefile_block_end(lines, start_index)
2117
+ return start_index unless lines[start_index].match?(/\bdo\b/)
2118
+
2119
+ depth = 0
2120
+ (start_index...lines.length).each do |index|
2121
+ stripped = lines[index].strip
2122
+ depth += 1 if stripped.match?(/\bdo\b/)
2123
+ return index if depth.positive? && stripped == "end" && (depth -= 1).zero?
2124
+ return index if depth.zero? && index > start_index && !stripped.empty?
2125
+ end
2126
+ lines.length - 1
2127
+ end
2128
+
2129
+ def rakefile_selector(selector_id, start_line, end_line, reason)
2130
+ {
2131
+ selector_id: selector_id,
2132
+ selector_family: "structural_owner_range",
2133
+ start_line: start_line,
2134
+ end_line: end_line,
2135
+ reason: reason,
2136
+ }
2137
+ end
2138
+
2139
+ def delete_line_ranges(content, selectors)
2140
+ lines = content.lines
2141
+ selectors.sort_by { |selector| -selector.fetch(:start_line) }.each do |selector|
2142
+ start_index = selector.fetch(:start_line) - 1
2143
+ end_index = selector.fetch(:end_line) - 1
2144
+ lines.slice!(start_index..end_index)
2145
+ end
2146
+ lines.join.gsub(/\n{3,}/, "\n\n")
2147
+ end
2148
+
2149
+ def synchronize_github_actions_ci(_content, facts)
2150
+ package = facts.fetch(:package)
2151
+ ci = facts.fetch(:ci)
2152
+ ruby_versions = ci.fetch(:ruby_versions)
2153
+ ruby_matrix = ruby_versions.map { |version| " - \"#{version}\"" }.join("\n")
2154
+
2155
+ <<~YAML
2156
+ name: CI
2157
+
2158
+ permissions:
2159
+ contents: read
2160
+
2161
+ on:
2162
+ push:
2163
+ branches:
2164
+ - "#{ci.fetch(:default_branch)}"
2165
+ - "*-stable"
2166
+ tags:
2167
+ - "!*" # Do not execute on tags
2168
+ pull_request:
2169
+ branches:
2170
+ - "*"
2171
+ workflow_dispatch:
2172
+
2173
+ concurrency:
2174
+ group: "${{ github.workflow }}-${{ github.ref }}"
2175
+ cancel-in-progress: true
2176
+
2177
+ jobs:
2178
+ test:
2179
+ if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')"
2180
+ name: Specs ${{ matrix.ruby }}
2181
+ runs-on: ubuntu-latest
2182
+ continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}
2183
+ strategy:
2184
+ fail-fast: false
2185
+ matrix:
2186
+ ruby:
2187
+ #{ruby_matrix}
2188
+ rubygems:
2189
+ - default
2190
+ bundler:
2191
+ - default
2192
+
2193
+ steps:
2194
+ - name: Checkout #{package.fetch(:name)}
2195
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
2196
+
2197
+ - name: Setup Ruby & RubyGems
2198
+ uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0
2199
+ with:
2200
+ ruby-version: "${{ matrix.ruby }}"
2201
+ rubygems: "${{ matrix.rubygems }}"
2202
+ bundler: "${{ matrix.bundler }}"
2203
+ bundler-cache: true
2204
+
2205
+ - name: Tests
2206
+ run: bundle exec rake
2207
+ YAML
2208
+ end
2209
+
2210
+ def synchronize_github_actions_framework_ci(_content, facts)
2211
+ ci = facts.fetch(:ci)
2212
+ framework_matrix = ci.fetch(:framework_matrix)
2213
+ ruby_matrix = ci.fetch(:ruby_versions).map { |version| " - \"#{version}\"" }.join("\n")
2214
+ include_matrix = framework_matrix.fetch(:include).map do |entry|
2215
+ [
2216
+ " - framework_version: \"#{entry.fetch(:framework_version)}\"",
2217
+ " gemfile: \"#{entry.fetch(:gemfile)}\"",
2218
+ ].join("\n")
2219
+ end.join("\n")
2220
+ dimension = framework_matrix.fetch(:dimension)
2221
+ label = dimension.split(/[-_]/).map { |part| part[0].to_s.upcase + part[1..].to_s }.join(" ")
2222
+
2223
+ <<~YAML
2224
+ name: #{label} CI
2225
+
2226
+ permissions:
2227
+ contents: read
2228
+
2229
+ on:
2230
+ push:
2231
+ branches:
2232
+ - "#{ci.fetch(:default_branch)}"
2233
+ - "*-stable"
2234
+ tags:
2235
+ - "!*" # Do not execute on tags
2236
+ pull_request:
2237
+ branches:
2238
+ - "*"
2239
+ workflow_dispatch:
2240
+
2241
+ concurrency:
2242
+ group: "${{ github.workflow }}-${{ github.ref }}"
2243
+ cancel-in-progress: true
2244
+
2245
+ jobs:
2246
+ test:
2247
+ if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')"
2248
+ name: Specs ${{ matrix.ruby }}@${{ matrix.framework_version }}
2249
+ runs-on: ubuntu-latest
2250
+ continue-on-error: ${{ endsWith(matrix.ruby, 'head') }}
2251
+ env:
2252
+ BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}
2253
+ strategy:
2254
+ fail-fast: false
2255
+ matrix:
2256
+ ruby:
2257
+ #{ruby_matrix}
2258
+ rubygems:
2259
+ - default
2260
+ bundler:
2261
+ - default
2262
+ include:
2263
+ #{include_matrix}
2264
+
2265
+ steps:
2266
+ - name: Checkout
2267
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
2268
+
2269
+ - name: Setup Ruby & RubyGems
2270
+ uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0
2271
+ with:
2272
+ ruby-version: "${{ matrix.ruby }}"
2273
+ rubygems: "${{ matrix.rubygems }}"
2274
+ bundler: "${{ matrix.bundler }}"
2275
+ bundler-cache: true
2276
+
2277
+ - name: Tests for ${{ matrix.ruby }}@${{ matrix.framework_version }}
2278
+ run: bundle exec rake test
2279
+ YAML
2280
+ end
2281
+
2282
+ def synchronize_github_actions_coverage_ci(_content, facts)
2283
+ ci = facts.fetch(:ci)
2284
+ coverage = ci.fetch(:coverage)
2285
+ <<~YAML
2286
+ name: Test Coverage
2287
+
2288
+ permissions:
2289
+ contents: read
2290
+ pull-requests: write
2291
+ id-token: write
2292
+
2293
+ env:
2294
+ K_SOUP_COV_MIN_BRANCH: 100
2295
+ K_SOUP_COV_MIN_LINE: 100
2296
+ K_SOUP_COV_MIN_HARD: true
2297
+ K_SOUP_COV_FORMATTERS: "xml,rcov,lcov,tty"
2298
+ K_SOUP_COV_DO: true
2299
+ K_SOUP_COV_MULTI_FORMATTERS: true
2300
+ K_SOUP_COV_COMMAND_NAME: "Test Coverage"
2301
+
2302
+ on:
2303
+ push:
2304
+ branches:
2305
+ - "#{ci.fetch(:default_branch)}"
2306
+ - "*-stable"
2307
+ tags:
2308
+ - "!*" # Do not execute on tags
2309
+ pull_request:
2310
+ branches:
2311
+ - "*"
2312
+ workflow_dispatch:
2313
+
2314
+ concurrency:
2315
+ group: "${{ github.workflow }}-${{ github.ref }}"
2316
+ cancel-in-progress: true
2317
+
2318
+ jobs:
2319
+ coverage:
2320
+ if: "!contains(github.event.commits[0].message, '[ci skip]') && !contains(github.event.commits[0].message, '[skip ci]')"
2321
+ name: Code Coverage on ${{ matrix.ruby }}@current
2322
+ runs-on: ubuntu-latest
2323
+ continue-on-error: ${{ matrix.experimental || endsWith(matrix.ruby, 'head') }}
2324
+ env:
2325
+ BUNDLE_GEMFILE: ${{ github.workspace }}/${{ matrix.gemfile }}.gemfile
2326
+ strategy:
2327
+ fail-fast: false
2328
+ matrix:
2329
+ include:
2330
+ - ruby: "ruby"
2331
+ appraisal: "#{coverage.fetch(:appraisal)}"
2332
+ exec_cmd: "#{coverage.fetch(:command)}"
2333
+ gemfile: "Appraisal.root"
2334
+ rubygems: latest
2335
+ bundler: latest
2336
+
2337
+ steps:
2338
+ - name: Checkout
2339
+ uses: actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2
2340
+
2341
+ - name: Setup Ruby & RubyGems
2342
+ uses: ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0
2343
+ with:
2344
+ ruby-version: "${{ matrix.ruby }}"
2345
+ rubygems: "${{ matrix.rubygems }}"
2346
+ bundler: "${{ matrix.bundler }}"
2347
+ bundler-cache: true
2348
+
2349
+ - name: "[Attempt 1] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}"
2350
+ id: bundleAppraisalAttempt1
2351
+ run: bundle exec appraisal ${{ matrix.appraisal }} install
2352
+ continue-on-error: true
2353
+
2354
+ - name: "[Attempt 2] Appraisal for ${{ matrix.ruby }}@${{ matrix.appraisal }}"
2355
+ id: bundleAppraisalAttempt2
2356
+ if: ${{ steps.bundleAppraisalAttempt1.outcome == 'failure' }}
2357
+ run: bundle exec appraisal ${{ matrix.appraisal }} install
2358
+
2359
+ - name: Tests for ${{ matrix.ruby }}@current via ${{ matrix.exec_cmd }}
2360
+ run: bundle exec appraisal ${{ matrix.appraisal }} bundle exec ${{ matrix.exec_cmd }}
2361
+ #{github_actions_coverage_steps}
2362
+ YAML
2363
+ end
2364
+
2365
+ def synchronize_github_actions_workflow_snippets(content)
2366
+ updated = ensure_workflow_top_level_section(
2367
+ content.to_s,
2368
+ "permissions",
2369
+ "permissions:\n contents: read\n\n",
2370
+ before: "on"
2371
+ )
2372
+ updated = ensure_workflow_top_level_section(
2373
+ updated,
2374
+ "concurrency",
2375
+ "concurrency:\n group: \"${{ github.workflow }}-${{ github.ref }}\"\n cancel-in-progress: true\n\n",
2376
+ before: "jobs"
2377
+ )
2378
+ updated = append_github_actions_coverage_steps(updated) if github_actions_coverage_enabled?(updated)
2379
+ update_github_actions_pins(updated)
2380
+ end
2381
+
2382
+ def github_actions_coverage_enabled?(content)
2383
+ content.match?(/K_SOUP_COV_DO:\s*["']?true["']?/)
2384
+ end
2385
+
2386
+ def append_github_actions_coverage_steps(content)
2387
+ return content if content.include?("Upload coverage to Coveralls") || content.include?("Upload coverage to CodeCov")
2388
+
2389
+ lines = content.lines
2390
+ steps_index = lines.index { |line| line.match?(/^ steps:\s*$/) }
2391
+ return content unless steps_index
2392
+
2393
+ insert_index = lines.length
2394
+ ((steps_index + 1)...lines.length).each do |index|
2395
+ line = lines[index]
2396
+ next if line.strip.empty?
2397
+ next unless line.match?(/^\S|^ \S|^ \S/) && !line.match?(/^ /)
2398
+
2399
+ insert_index = index
2400
+ break
2401
+ end
2402
+ lines.insert(insert_index, github_actions_coverage_steps)
2403
+ lines.join
2404
+ end
2405
+
2406
+ def github_actions_coverage_steps
2407
+ <<~YAML.lines.map { |line| line.strip.empty? ? line : " #{line}" }.join
2408
+ - name: Upload coverage to Coveralls
2409
+ if: ${{ !env.ACT }}
2410
+ uses: coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main
2411
+ with:
2412
+ github-token: ${{ secrets.GITHUB_TOKEN }}
2413
+ continue-on-error: ${{ matrix.experimental != 'false' }}
2414
+
2415
+ - name: Upload coverage to QLTY
2416
+ if: ${{ !env.ACT }}
2417
+ uses: qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0
2418
+ with:
2419
+ token: ${{secrets.QLTY_COVERAGE_TOKEN}}
2420
+ files: coverage/.resultset.json
2421
+ continue-on-error: ${{ matrix.experimental != 'false' }}
2422
+
2423
+ - name: Upload coverage to CodeCov
2424
+ if: ${{ !env.ACT }}
2425
+ uses: codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0
2426
+ with:
2427
+ use_oidc: true
2428
+ fail_ci_if_error: false
2429
+ files: coverage/lcov.info,coverage/coverage.xml
2430
+ verbose: true
2431
+
2432
+ - name: Code Coverage Summary Report
2433
+ if: ${{ !env.ACT && github.event_name == 'pull_request' }}
2434
+ uses: irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0
2435
+ with:
2436
+ filename: ./coverage/coverage.xml
2437
+ badge: true
2438
+ fail_below_min: true
2439
+ format: markdown
2440
+ hide_branch_rate: false
2441
+ hide_complexity: true
2442
+ indicators: true
2443
+ output: both
2444
+ thresholds: '100 100'
2445
+ continue-on-error: ${{ matrix.experimental != 'false' }}
2446
+
2447
+ - name: Add Coverage PR Comment
2448
+ uses: marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4
2449
+ if: ${{ !env.ACT && github.event_name == 'pull_request' }}
2450
+ with:
2451
+ recreate: true
2452
+ path: code-coverage-results.md
2453
+ continue-on-error: ${{ matrix.experimental != 'false' }}
2454
+ YAML
2455
+ end
2456
+
2457
+ def ensure_workflow_top_level_section(content, key, section, before:)
2458
+ return content if content.match?(/^#{Regexp.escape(key)}:/)
2459
+
2460
+ lines = content.lines
2461
+ index = lines.index { |line| line.match?(/^#{Regexp.escape(before)}:/) }
2462
+ if index
2463
+ prepared_section = index.zero? || lines[index - 1].strip.empty? ? section : "\n#{section}"
2464
+ lines.insert(index, prepared_section)
2465
+ else
2466
+ lines << "\n" unless lines.empty? || lines.last == "\n"
2467
+ lines << section
2468
+ end
2469
+ lines.join
2470
+ end
2471
+
2472
+ def update_github_actions_pins(content)
2473
+ github_actions_step_pins.reduce(content) do |updated, (action_prefix, pinned_value)|
2474
+ updated.gsub(/^(\s*(?:-\s*)?uses:\s*)#{Regexp.escape(action_prefix)}@\S+(?:\s+#.*)?$/) do
2475
+ "#{$1}#{pinned_value}"
2476
+ end
2477
+ end
2478
+ end
2479
+
2480
+ def github_actions_step_pins
2481
+ {
2482
+ "actions/checkout" => "actions/checkout@de0fac2e4500dabe0009e67214ff5f5447ce83dd # v6.0.2",
2483
+ "ruby/setup-ruby" => "ruby/setup-ruby@e65c17d16e57e481586a6a5a0282698790062f92 # v1.300.0",
2484
+ "coverallsapp/github-action" => "coverallsapp/github-action@0a51d2e0b5417d06e4ecceb534aec87defc53926 # main",
2485
+ "qltysh/qlty-action/coverage" => "qltysh/qlty-action/coverage@a19242102d17e497f437d7466aa01b528537e899 # v2.2.0",
2486
+ "codecov/codecov-action" => "codecov/codecov-action@57e3a136b779b570ffcdbf80b3bdc90e7fab3de2 # v6.0.0",
2487
+ "irongut/CodeCoverageSummary" => "irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0",
2488
+ "marocchino/sticky-pull-request-comment" => "marocchino/sticky-pull-request-comment@0ea0beb66eb9baf113663a64ec522f60e49231c0 # v3.0.4",
2489
+ }
2490
+ end
2491
+
2492
+ def replace_markdown_managed_block(content, marker, replacement)
2493
+ open = "<!-- #{marker}:start -->"
2494
+ close = "<!-- #{marker}:end -->"
2495
+ replace_between_markers(content, open, close, replacement) do
2496
+ [content.rstrip, "", replacement, ""].join("\n")
2497
+ end
2498
+ end
2499
+
2500
+ def replace_text_managed_block(content, replacement)
2501
+ replace_between_markers(content, MANAGED_BLOCK_OPEN, MANAGED_BLOCK_CLOSE, replacement) do
2502
+ [content.rstrip, replacement].reject(&:empty?).join("\n")
2503
+ end
2504
+ end
2505
+
2506
+ def replace_between_markers(content, open_marker, close_marker, replacement)
2507
+ open_index = content.index(open_marker)
2508
+ close_index = content.index(close_marker)
2509
+ return yield unless open_index && close_index && close_index >= open_index
2510
+
2511
+ close_end = close_index + close_marker.length
2512
+ close_end += 1 if content[close_end] == "\n"
2513
+ "#{content[0...open_index]}#{replacement}\n#{content[close_end..]}"
2514
+ end
2515
+
2516
+ def ensure_trailing_newline(text)
2517
+ text.end_with?("\n") ? text : "#{text}\n"
2518
+ end
2519
+
2520
+ def compact_hash(hash)
2521
+ hash.reject { |_key, value| value.nil? || (value.respond_to?(:empty?) && value.empty?) }
2522
+ end
2523
+
2524
+ def deep_dup(value)
2525
+ Marshal.load(Marshal.dump(value))
2526
+ end
2527
+ end
2528
+ end