kettle-jem 7.1.28 → 7.1.30

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +220 -1
  4. data/README.md +2 -114
  5. data/lib/kettle/jem/maintenance_changelog.rb +1 -1
  6. data/lib/kettle/jem/relative_paths.rb +61 -0
  7. data/lib/kettle/jem/tasks/install_task.rb +9 -5
  8. data/lib/kettle/jem/tasks/prepare_task.rb +29 -10
  9. data/lib/kettle/jem/tasks/template_task.rb +18 -3
  10. data/lib/kettle/jem/templates/.devcontainer/devcontainer.json.example +1 -1
  11. data/lib/kettle/jem/templates/.git-hooks/prepare-commit-msg.example +3 -4
  12. data/lib/kettle/jem/templates/.github/workflows/coverage.yml.example +24 -2
  13. data/lib/kettle/jem/templates/.github/workflows/current.yml.example +13 -14
  14. data/lib/kettle/jem/templates/.github/workflows/dep-heads.yml.example +8 -5
  15. data/lib/kettle/jem/templates/.github/workflows/framework-ci.yml.example +4 -1
  16. data/lib/kettle/jem/templates/.github/workflows/heads.yml.example +16 -66
  17. data/lib/kettle/jem/templates/.github/workflows/jruby-10.0.yml.example +16 -3
  18. data/lib/kettle/jem/templates/.github/workflows/jruby-9.1.yml.example +16 -3
  19. data/lib/kettle/jem/templates/.github/workflows/jruby-9.2.yml.example +16 -3
  20. data/lib/kettle/jem/templates/.github/workflows/jruby-9.3.yml.example +16 -3
  21. data/lib/kettle/jem/templates/.github/workflows/jruby-9.4.yml.example +16 -3
  22. data/lib/kettle/jem/templates/.github/workflows/jruby.yml.example +16 -3
  23. data/lib/kettle/jem/templates/.github/workflows/license-eye.yml.example +22 -1
  24. data/lib/kettle/jem/templates/.github/workflows/locked_deps.yml.example +2 -1
  25. data/lib/kettle/jem/templates/.github/workflows/opencollective.yml.example +1 -1
  26. data/lib/kettle/jem/templates/.github/workflows/ruby-2.3.yml.example +8 -2
  27. data/lib/kettle/jem/templates/.github/workflows/ruby-2.4.yml.example +8 -2
  28. data/lib/kettle/jem/templates/.github/workflows/ruby-2.5.yml.example +8 -2
  29. data/lib/kettle/jem/templates/.github/workflows/ruby-2.6.yml.example +8 -2
  30. data/lib/kettle/jem/templates/.github/workflows/ruby-2.7.yml.example +8 -2
  31. data/lib/kettle/jem/templates/.github/workflows/ruby-3.0.yml.example +9 -3
  32. data/lib/kettle/jem/templates/.github/workflows/ruby-3.1.yml.example +8 -2
  33. data/lib/kettle/jem/templates/.github/workflows/ruby-3.2.yml.example +8 -2
  34. data/lib/kettle/jem/templates/.github/workflows/ruby-3.3.yml.example +8 -2
  35. data/lib/kettle/jem/templates/.github/workflows/ruby-3.4.yml.example +8 -2
  36. data/lib/kettle/jem/templates/.github/workflows/style.yml.example +5 -2
  37. data/lib/kettle/jem/templates/.github/workflows/templating.yml.example +18 -2
  38. data/lib/kettle/jem/templates/.github/workflows/truffle.yml.example +16 -3
  39. data/lib/kettle/jem/templates/.github/workflows/truffleruby-22.3.yml.example +16 -3
  40. data/lib/kettle/jem/templates/.github/workflows/truffleruby-23.1.yml.example +12 -2
  41. data/lib/kettle/jem/templates/.github/workflows/truffleruby-24.2.yml.example +16 -3
  42. data/lib/kettle/jem/templates/.github/workflows/truffleruby-25.0.yml.example +16 -3
  43. data/lib/kettle/jem/templates/.github/workflows/truffleruby-33.0.yml.example +16 -3
  44. data/lib/kettle/jem/templates/.github/workflows/unlocked_deps.yml.example +6 -2
  45. data/lib/kettle/jem/templates/.structuredmerge/kettle-jem.yml.example +19 -0
  46. data/lib/kettle/jem/templates/Appraisal.root.gemfile.example +2 -1
  47. data/lib/kettle/jem/templates/Appraisals.example +4 -1
  48. data/lib/kettle/jem/templates/CHANGELOG.transfer.md +6 -0
  49. data/lib/kettle/jem/templates/Gemfile.example +12 -3
  50. data/lib/kettle/jem/templates/README.md.example +5 -5
  51. data/lib/kettle/jem/templates/Rakefile.example +2 -0
  52. data/lib/kettle/jem/templates/bin/turbo_tests2.example +14 -0
  53. data/lib/kettle/jem/templates/gem.gemspec.example +6 -6
  54. data/lib/kettle/jem/templates/gemfiles/modular/coverage.gemfile.example +1 -1
  55. data/lib/kettle/jem/templates/gemfiles/modular/coverage_local.gemfile.example +10 -12
  56. data/lib/kettle/jem/templates/gemfiles/modular/documentation.gemfile.example +5 -5
  57. data/lib/kettle/jem/templates/gemfiles/modular/ostruct/r4/v0.6.gemfile.example +2 -0
  58. data/lib/kettle/jem/templates/gemfiles/modular/style.gemfile.example +15 -8
  59. data/lib/kettle/jem/templates/gemfiles/modular/style_local.gemfile.example +7 -1
  60. data/lib/kettle/jem/templates/gemfiles/modular/templating.gemfile.example +3 -2
  61. data/lib/kettle/jem/templates/gemfiles/modular/templating_local.gemfile.example +18 -19
  62. data/lib/kettle/jem/templates/gemfiles/modular/x_std_libs/r4/libs.gemfile.example +1 -0
  63. data/lib/kettle/jem/templates/mise.toml.example +1 -1
  64. data/lib/kettle/jem/templates/mise.toml.no-osc.example +1 -1
  65. data/lib/kettle/jem/templates/readme/partials/configuration.md.example +4 -3
  66. data/lib/kettle/jem/templates/shim/Gemfile.example +9 -3
  67. data/lib/kettle/jem/templates/shim/gem.gemspec.example +3 -3
  68. data/lib/kettle/jem/version.rb +1 -1
  69. data/lib/kettle/jem.rb +1214 -723
  70. data.tar.gz.sig +0 -0
  71. metadata +28 -67
  72. metadata.gz.sig +0 -0
  73. data/lib/kettle/jem/templates/.github/workflows/truffleruby-23.0.yml.example +0 -92
  74. data/lib/kettle/jem/templates/gemfiles/modular/json/truffleruby_23_0.gemfile.example +0 -6
data/lib/kettle/jem.rb CHANGED
@@ -23,9 +23,12 @@ require "rbs"
23
23
  require "kettle/dev"
24
24
  require "kettle/changelog"
25
25
  require "kettle/rb/compat_matrix"
26
+ require "kettle/rb/gem_floors"
27
+ require "kettle/rb/platform_support"
26
28
  require_relative "jem/version"
27
29
  require_relative "jem/license_txt_migrator"
28
30
  require_relative "jem/maintenance_changelog"
31
+ require_relative "jem/relative_paths"
29
32
 
30
33
  begin
31
34
  require "kettle/drift"
@@ -51,17 +54,18 @@ module Kettle
51
54
  MANAGED_BLOCK_OPEN = "# <<kettle-jem:generated>> do not edit below this line"
52
55
  MANAGED_BLOCK_CLOSE = "# <</kettle-jem:generated>>"
53
56
  GEMSPEC_DEPENDENCY_MINIMUM_REQUIREMENTS = {
54
- "rspec-stubbed_env" => ">= 1.0.6"
57
+ "rspec-stubbed_env" => ">= 1.0.17"
55
58
  }.freeze
56
59
  # Template dependency transitions are location-independent. Prepare finds
57
60
  # declarations in the destination and reconciles their complete requirement
58
61
  # set in place before Bundler is allowed to evaluate that destination.
59
62
  TEMPLATE_MANAGED_DEPENDENCIES = [
60
- {name: "nomono", requirements: ["~> 1.1", ">= 1.1.5"], bootstrap: true},
61
- {name: "kettle-dev", requirements: ["~> 3.1", ">= 3.1.0"], bootstrap: true},
63
+ {name: PACKAGE_NAME, requirements: ["~> 7.1", ">= 7.1.28"], bootstrap: false},
64
+ {name: "nomono", requirements: ["~> 1.1", ">= 1.1.7"], bootstrap: true},
65
+ {name: "kettle-dev", requirements: ["~> 3.1", ">= 3.1.13"], bootstrap: true},
62
66
  {
63
67
  name: "kettle-changelog",
64
- requirements: ["~> 1.0", ">= 1.0.7"],
68
+ requirements: ["~> 1.0", ">= 1.0.10"],
65
69
  bootstrap: true,
66
70
  ruby_requirement: ">= 4.0.0"
67
71
  }
@@ -89,14 +93,99 @@ module Kettle
89
93
  "kettle-test -I ../spec --options ../.rspec ../spec"
90
94
  ].freeze
91
95
  DEFAULT_ENGINES = %w[ruby jruby truffleruby].freeze
96
+ # CI OS families a destination supports when it declares no `platforms:` key.
97
+ # Windows is opt-in only: Windows CI is materially slower and most gems do
98
+ # not need it, so a destination that wants it must declare it. The values
99
+ # are Kettle::Rb::PlatformSupport OS families, not Gem::Platform strings.
100
+ DEFAULT_PLATFORMS = %w[linux macos].freeze
101
+ # Matches GitHub Windows runner labels (windows-latest, windows-2022,
102
+ # windows-11-arm, ...) anywhere in a workflow file, which covers both a
103
+ # direct `runs-on:` and the `matrix.os` indirection. Validated against the
104
+ # packaged templates: their only Windows token is windows-latest, and
105
+ # `runs-on:` only ever appears as `${{ matrix.os }}` or an ubuntu label.
106
+ WINDOWS_RUNNER_LABEL_PATTERN = /windows-(?:latest|\d+)/i
107
+ # Documentation injected with `platforms:` by #migrate_platforms_config.
108
+ # Kept in sync with the packaged config template
109
+ # (templates/.structuredmerge/kettle-jem.yml.example).
110
+ PLATFORMS_CONFIG_DOCUMENTATION = [
111
+ "# List the OS platforms this project's CI exercises. kettle-jem uses this to",
112
+ "# generate the current.yml OS matrix and the README platform tier table.",
113
+ "# Windows applies only when declared: the `if: matrix.os` guards and the",
114
+ "# Windows-only test step are emitted solely for destinations that opt in.",
115
+ "#",
116
+ "# Supported values: linux, macos, windows",
117
+ "# Default (when key is absent): linux, macos",
118
+ "# These are CI OS families, a distinct vocabulary from Gem::Platform strings",
119
+ "# and Gemfile DSL platform symbols; never reuse them for",
120
+ "# `bundle lock --add-platform`."
121
+ ].freeze
92
122
  DEFAULT_OPENCOLLECTIVE_ORG = "galtzo-floss"
93
123
  # These tools remain active, but their dependency ownership belongs to the
94
124
  # generated modular Gemfiles rather than a destination gemspec.
95
125
  EXTERNALIZED_GEMSPEC_DEVELOPMENT_DEPENDENCIES = %w[kettle-drift kettle-soup-cover rubocop-rspec yard-junk].freeze
96
- # These legacy release tools must not be retained in any managed dependency
97
- # declaration. Kettle Dev owns release workflows.
98
- PROHIBITED_GEMSPEC_DEPENDENCIES = %w[gem-release].freeze
126
+ # Gems superseded by a template-provided replacement, wrong everywhere
127
+ # regardless of whether the template places a replacement anywhere for
128
+ # this destination (case 2 of the dev-dependency conflict policy):
129
+ # unconditional removal, no review needed. gem-release is a legacy
130
+ # release tool - Kettle Dev owns release workflows. appraisal and
131
+ # turbo_tests were replaced by appraisal2 and turbo_tests2.
132
+ PROHIBITED_GEMSPEC_DEPENDENCIES = %w[gem-release appraisal turbo_tests].freeze
99
133
  PROHIBITED_GEMFILE_DEPENDENCIES = PROHIBITED_GEMSPEC_DEPENDENCIES.freeze
134
+ # Git sources Bundler already defines. A Gemfile override (for example an
135
+ # SSH `git_source(:github)`) replaces Bundler's own HTTPS source and is
136
+ # recorded in Gemfile.lock, where CI cannot clone it: unconditional removal.
137
+ BUNDLER_BUILTIN_GIT_SOURCES = %w[github gist bitbucket].freeze
138
+ STRUCTUREDMERGE_GEMS = %w[
139
+ ast-crispr ast-crispr-markdown-markly ast-crispr-ruby-prism ast-merge ast-merge-git
140
+ ast-template bash-merge binary-merge citrus-toml-merge commonmarker-merge dotenv-merge
141
+ go-merge html-merge json-merge kettle-jem kramdown-merge markdown-merge markly-merge
142
+ parslet-toml-merge plain-merge prism-merge psych-merge rbs-merge ruby-merge rust-merge
143
+ smorg-rb toml-merge tree_haver typescript-merge yaml-merge zip-merge
144
+ ].freeze
145
+ # Canonical resolutions for development-dependency gems whose handling is
146
+ # already known ahead of any specific destination project. Two
147
+ # categories today:
148
+ #
149
+ # :template_managed - the gem is always owned by a template modular
150
+ # Gemfile; a direct declaration is always a stale duplicate. Detected
151
+ # the same way as any other modular_dependency_conflicts entry (a
152
+ # direct declaration plus a modular declaration of the same gem
153
+ # name); when a newly surfaced conflict's gem matches one of these
154
+ # (and force_review isn't set), the real decided entry is written
155
+ # straight to dependency_conflicts.resolve instead of a placeholder
156
+ # "review" entry.
157
+ #
158
+ # :engine_incompatible - the gem doesn't support every engine the
159
+ # destination declares (config["engines"], falling back to
160
+ # DEFAULT_ENGINES) and has no template-managed modular home (below the
161
+ # template's inclusion threshold). Detected by #engine_dependency_conflicts, a
162
+ # separate engine-declaration-based check (comparing supported_engines
163
+ # against the destination's declared engines) rather than by
164
+ # modular_dependency_conflicts, since there's no modular-side
165
+ # declaration to match against. sqlite3 (the pilot entry) sets
166
+ # force_review: true because there's no single correct action across
167
+ # destinations - only the destination's own declared engines: decide
168
+ # it. A future entry in this category with force_review: false would
169
+ # auto-resolve via #engine_dependency_conflicts the same way
170
+ # :template_managed does via modular_dependency_conflicts (both
171
+ # funnel into the same auto_resolvable_known_gem_conflict? gate); its
172
+ # registry action would need to make sense with no modular
173
+ # counterpart to defer to, e.g. an unconditional removal.
174
+ KNOWN_GEM_CONFLICT_RESOLUTIONS = {
175
+ "debug" => {
176
+ category: :template_managed,
177
+ action: "remove_direct_gem",
178
+ reason: "debug is template-managed via gemfiles/modular/debug.gemfile (platform :mri); it ships a native C extension with no jruby/truffleruby release.",
179
+ force_review: false
180
+ }.freeze,
181
+ "sqlite3" => {
182
+ category: :engine_incompatible,
183
+ action: "review",
184
+ reason: "sqlite3 has no jruby-platform release on rubygems.org and is not template-managed; declare it in a modular gemfile with platforms: [:ruby] instead of a direct dependency (kettle-jem-appraisals generates gemfiles/modular/activerecord_support/*.gemfile, with the JDBC adapter on JRuby), or resolve per destination based on declared engines.",
185
+ supported_engines: %w[ruby].freeze,
186
+ force_review: true
187
+ }.freeze
188
+ }.freeze
100
189
  FILE_DELETION_PRIMITIVES = %w[
101
190
  supplied_obsolete_file_deletion
102
191
  supplied_opt_in_workflow_deletion
@@ -423,7 +512,6 @@ module Kettle
423
512
  TEMPLATE_SOURCE_APPLICATION_RECIPE = /\Atemplate_source_application_/
424
513
  DECISION_NO_WRITE_ACTIONS = %w[keep skip].freeze
425
514
  RUBY_TEMPLATE_POLICY_FILE_TYPES = %i[gemfile gemspec appraisals].freeze
426
- GEMFILE_POLICY_SELF_DEPENDENCIES = %w[appraisal].freeze
427
515
  TEMPLATE_CONTENT_PRIMITIVES = %w[
428
516
  supplied_kettle_config_bootstrap
429
517
  supplied_template_source_preference
@@ -484,6 +572,7 @@ module Kettle
484
572
  KJ|MIN_RUBY
485
573
  KJ|KETTLE_CHANGELOG_GEMFILE_DEPENDENCY
486
574
  KJ|OPENCOLLECTIVE_ORG
575
+ KJ|RAKE:TOP_LEVEL_DOCS_TASK
487
576
  KJ|README:COPYRIGHT_NOTICE
488
577
  KJ|README:CORPORATE_SPONSORS
489
578
  KJ|README:FAMILY_INTRO_BACKEND_MATRIX
@@ -533,39 +622,10 @@ module Kettle
533
622
  }
534
623
  ])
535
624
  README_TOP_LOGO_TYPES = %w[related_org ruby language org project affiliated_project].freeze
536
- APPRAISAL_NAME_PREFIX = "kja"
537
- APPRAISAL_GEM_ABBREVIATIONS = {
538
- "activerecord" => "ar",
539
- "actionmailer" => "am",
540
- "actionpack" => "ap",
541
- "activesupport" => "as",
542
- "activejob" => "aj",
543
- "actioncable" => "ac",
544
- "actionview" => "av",
545
- "activestorage" => "ast",
546
- "actionmailbox" => "amb",
547
- "actiontext" => "at",
548
- "omniauth" => "oa",
549
- "mongoid" => "mo",
550
- "sequel" => "sq",
551
- "couch_potato" => "cp",
552
- "rom" => "rom",
553
- "rom-sql" => "rsql"
554
- }.freeze
555
- APPRAISAL_WORKFLOW_LIFECYCLE_RANGES = Ractor.make_shareable({
556
- "current" => {min: Gem::Version.new("3.4"), max: Gem::Version.new("3.99")},
557
- "supported" => {min: Gem::Version.new("3.2"), max: Gem::Version.new("3.3")},
558
- "legacy" => {min: Gem::Version.new("3.0"), max: Gem::Version.new("3.1")},
559
- "unsupported" => {min: Gem::Version.new("2.6"), max: Gem::Version.new("2.7")},
560
- "ancient" => {min: Gem::Version.new("2.3"), max: Gem::Version.new("2.5")}
561
- })
562
- APPRAISAL_ALWAYS_EXCLUDED_GEMS = %w[version_gem].freeze
563
- APPRAISAL_VERSION_SELECTION_MODES = %w[major minor patch minor-minmax semver].freeze
564
625
  DEFAULT_TEST_MINIMUM_RUBY = Gem::Version.new("2.4")
565
626
  REQUIRE_RELATIVE_MIN_RUBY = Gem::Version.new("2.2").freeze
566
627
  ANONYMOUS_VERSION_LOADER_MIN_RUBY = Gem::Version.new("2.2").freeze
567
628
  MODERN_GEMSPEC_VERSION_LOADER_MIN_RUBY = Gem::Version.new("3.1").freeze
568
- APPRAISAL_DEFAULT_FRESHNESS_TTL = 604_800
569
629
  DECISION_ACTIONS = %w[create merge replace keep delete skip].freeze
570
630
  DECISION_SEVERITIES = %w[advisory warning fatal].freeze
571
631
 
@@ -2609,6 +2669,74 @@ module Kettle
2609
2669
  require "ast/crispr/ruby/prism"
2610
2670
  @runtime_dependencies_loaded = true
2611
2671
  end
2672
+
2673
+ # Grammars templating merges through tree_sitter_language_pack. Prefetching
2674
+ # them through TSLP's hot-load API downloads and loads the grammars up
2675
+ # front so the on-demand download cannot transiently fail inside a merge
2676
+ # and surface as a misleading "No parser registered" abort.
2677
+ TSLP_PREFETCH_LANGUAGES = %w[
2678
+ bash
2679
+ go
2680
+ html
2681
+ json
2682
+ json5
2683
+ markdown
2684
+ rbs
2685
+ ruby
2686
+ rust
2687
+ toml
2688
+ tsx
2689
+ typescript
2690
+ yaml
2691
+ ].freeze
2692
+
2693
+ def tslp_prefetch_languages(env)
2694
+ override = env["KJ_TSLP_PREFETCH_LANGUAGES"]
2695
+ return TSLP_PREFETCH_LANGUAGES if override.nil? || override.to_s.strip.empty?
2696
+
2697
+ override.to_s.split(/[\s,]+/).map(&:downcase).uniq
2698
+ end
2699
+
2700
+ def prefetch_tslp_grammars(env: ENV, events: nil)
2701
+ override = env["KJ_TSLP_PREFETCH_LANGUAGES"]
2702
+ if DecisionPolicy.value_to_boolean(override) == false
2703
+ return {attempted: false, reason: "disabled via KJ_TSLP_PREFETCH_LANGUAGES"}
2704
+ end
2705
+
2706
+ languages = tslp_prefetch_languages(env)
2707
+ report = TreeHaver.prefetch_languages(languages)
2708
+ report[:languages] = languages
2709
+ if events
2710
+ emit_event(events, "diagnostic", {
2711
+ severity: report.fetch(:failures, {}).empty? ? "advisory" : "warning",
2712
+ category: "tslp_prefetch",
2713
+ message: tslp_prefetch_message(report)
2714
+ })
2715
+ end
2716
+ report
2717
+ rescue => error
2718
+ if events
2719
+ emit_event(events, "diagnostic", {
2720
+ severity: "warning",
2721
+ category: "tslp_prefetch",
2722
+ message: "TSLP grammar prefetch failed: #{error.class}: #{error.message}"
2723
+ })
2724
+ end
2725
+ {attempted: false, reason: "#{error.class}: #{error.message}"}
2726
+ end
2727
+
2728
+ def tslp_prefetch_message(report)
2729
+ return "TSLP grammar prefetch skipped: #{report[:reason]}" unless report.fetch(:attempted, false)
2730
+
2731
+ prefetched = Array(report[:prefetched])
2732
+ failures = report.fetch(:failures, {})
2733
+ message = "TSLP grammar prefetch loaded #{prefetched.length} grammar(s): #{prefetched.sort.join(", ")}"
2734
+ unless failures.empty?
2735
+ detail = failures.sort.map { |name, reason| "#{name}: #{reason}" }.join("; ")
2736
+ message += "; #{failures.length} failure(s): #{detail}"
2737
+ end
2738
+ message
2739
+ end
2612
2740
  # rubocop:enable ThreadSafety/ClassInstanceVariable
2613
2741
 
2614
2742
  def display_path(path)
@@ -2650,546 +2778,6 @@ module Kettle
2650
2778
  load(File.expand_path("jem/tasks.rb", __dir__))
2651
2779
  end
2652
2780
 
2653
- def appraisal_gem_abbreviation(gem_name)
2654
- APPRAISAL_GEM_ABBREVIATIONS.fetch(gem_name.to_s, gem_name.to_s)
2655
- end
2656
-
2657
- def appraisal_format_version(version)
2658
- version.to_s.tr(".", "-")
2659
- end
2660
-
2661
- def appraisal_name(tier1_gem:, tier1_version:, ruby_series:, tier2_gem: nil, tier2_version: nil)
2662
- parts = [
2663
- APPRAISAL_NAME_PREFIX,
2664
- appraisal_gem_abbreviation(tier1_gem),
2665
- appraisal_format_version(tier1_version)
2666
- ]
2667
- unless tier2_gem.to_s.empty?
2668
- parts << appraisal_gem_abbreviation(tier2_gem)
2669
- parts << appraisal_format_version(tier2_version)
2670
- end
2671
- parts << ruby_series.to_s
2672
- parts.join("-")
2673
- end
2674
-
2675
- def appraisal_modular_gemfile_path(gem_name:, version:, ruby_series:)
2676
- File.join("gemfiles", "modular", gem_name.to_s, ruby_series.to_s, "v#{version}.gemfile")
2677
- end
2678
-
2679
- def appraisal_modular_gemfile_content(gem_name:, version:, sub_dependencies: {})
2680
- lines = [
2681
- "# frozen_string_literal: true",
2682
- "",
2683
- "# Generated by kettle-jem",
2684
- "",
2685
- %(gem "#{gem_name}", "#{appraisal_version_requirement(version)}")
2686
- ]
2687
- sub_dependencies.each do |name, requirement|
2688
- lines << %(gem "#{name}", "~> #{requirement}")
2689
- end
2690
- ensure_trailing_newline(lines.join("\n"))
2691
- end
2692
-
2693
- def appraisal_version_requirement(version)
2694
- segments = version.to_s.split(".")
2695
- (segments.length >= 3) ? "~> #{version}" : "~> #{version}.0"
2696
- end
2697
-
2698
- def appraisal_file_content(matrix_entries)
2699
- lines = [
2700
- "# frozen_string_literal: true",
2701
- "",
2702
- "# Generated by kettle-jem",
2703
- "# Do not edit directly; regenerate from Kettle/Jem appraisal matrix metadata.",
2704
- ""
2705
- ]
2706
- matrix_entries.each do |entry|
2707
- lines << %(appraise "#{entry.fetch(:name)}" do)
2708
- lines << %( eval_gemfile "#{appraisal_eval_gemfile_path(entry.fetch(:tier1_gemfile))}") if entry[:tier1_gemfile]
2709
- lines << %( eval_gemfile "#{appraisal_eval_gemfile_path(entry.fetch(:tier2_gemfile))}") if entry[:tier2_gemfile]
2710
- lines << %( eval_gemfile "#{appraisal_eval_gemfile_path(entry.fetch(:x_std_libs_gemfile))}") if entry[:x_std_libs_gemfile]
2711
- lines << "end"
2712
- lines << ""
2713
- end
2714
- lines.pop while lines.last == ""
2715
- ensure_trailing_newline(lines.join("\n"))
2716
- end
2717
-
2718
- def appraisal_eval_gemfile_path(path)
2719
- path.to_s.delete_prefix("gemfiles/")
2720
- end
2721
-
2722
- def appraisal_workflow_groups(matrix_entries, bucket_ranges:, exec_cmd: "kettle-test")
2723
- grouped = Hash.new { |hash, key| hash[key] = [] }
2724
- normalized_ranges = bucket_ranges.transform_values do |range|
2725
- {
2726
- floor: Gem::Version.new((range[:floor] || range["floor"]).to_s),
2727
- ceiling: Gem::Version.new((range[:ceiling] || range["ceiling"]).to_s)
2728
- }
2729
- end
2730
- matrix_entries.each do |entry|
2731
- ruby_series = entry[:ruby_series] || entry["ruby_series"]
2732
- range = normalized_ranges[ruby_series]
2733
- next unless range
2734
-
2735
- lifecycle = appraisal_workflow_lifecycle(range.fetch(:floor))
2736
- grouped[lifecycle] << {
2737
- ruby: appraisal_workflow_ruby(range.fetch(:floor), lifecycle),
2738
- appraisal: entry[:name] || entry["name"],
2739
- exec_cmd: exec_cmd,
2740
- rubygems: "latest",
2741
- bundler: "latest"
2742
- }
2743
- end
2744
- grouped.transform_values { |entries| entries.sort_by { |entry| entry.fetch(:appraisal).to_s } }
2745
- end
2746
-
2747
- def appraisal_workflow_yaml_snippets(matrix_entries, bucket_ranges:, exec_cmd: "kettle-test")
2748
- appraisal_workflow_groups(matrix_entries, bucket_ranges: bucket_ranges, exec_cmd: exec_cmd).transform_values do |entries|
2749
- lines = ["strategy:", " matrix:", " include:"]
2750
- entries.each do |entry|
2751
- lines << %( - ruby: "#{entry.fetch(:ruby)}")
2752
- lines << %( appraisal: "#{entry.fetch(:appraisal)}")
2753
- lines << %( exec_cmd: "#{entry.fetch(:exec_cmd)}")
2754
- lines << %( rubygems: "#{entry.fetch(:rubygems)}")
2755
- lines << %( bundler: "#{entry.fetch(:bundler)}")
2756
- end
2757
- lines.join("\n")
2758
- end
2759
- end
2760
-
2761
- def appraisal_workflow_lifecycle(ruby_floor)
2762
- APPRAISAL_WORKFLOW_LIFECYCLE_RANGES.each do |name, range|
2763
- return name if ruby_floor.between?(range.fetch(:min), range.fetch(:max))
2764
- end
2765
- (ruby_floor < APPRAISAL_WORKFLOW_LIFECYCLE_RANGES.fetch("ancient").fetch(:min)) ? "ancient" : "current"
2766
- end
2767
-
2768
- def appraisal_workflow_ruby(ruby_floor, lifecycle)
2769
- return "ruby" if lifecycle == "current"
2770
-
2771
- segments = ruby_floor.segments
2772
- "#{segments[0]}.#{segments[1] || 0}"
2773
- end
2774
-
2775
- def appraisal_x_stdlib_exclusions(template_content)
2776
- gems = ruby_call_records(template_content, :eval_gemfile).filter_map do |call|
2777
- path = ruby_string_argument(call).to_s
2778
- next unless path.start_with?("../")
2779
-
2780
- path.delete_prefix("../").split("/").first
2781
- end
2782
- (gems + APPRAISAL_ALWAYS_EXCLUDED_GEMS).uniq.sort
2783
- end
2784
-
2785
- def appraisal_select_versions(version_metadata, mode:, requirements: nil)
2786
- mode = mode.to_s
2787
- raise ArgumentError, "invalid appraisal version selection mode: #{mode}" unless APPRAISAL_VERSION_SELECTION_MODES.include?(mode)
2788
-
2789
- versions = appraisal_filtered_versions(version_metadata, requirements: requirements)
2790
- return versions if mode == "patch"
2791
-
2792
- by_major = appraisal_minor_versions_by_major(versions)
2793
- return [] if by_major.empty?
2794
-
2795
- current_major = by_major.last.fetch(:major)
2796
- case mode
2797
- when "major"
2798
- by_major.map { |entry| entry.fetch(:minors).last }
2799
- when "minor"
2800
- by_major.flat_map { |entry| entry.fetch(:minors) }
2801
- when "minor-minmax"
2802
- by_major.flat_map do |entry|
2803
- minors = entry.fetch(:minors)
2804
- (entry.fetch(:major) < current_major) ? [minors.first, minors.last].uniq : minors
2805
- end
2806
- when "semver"
2807
- by_major.flat_map do |entry|
2808
- (entry.fetch(:major) < current_major) ? [entry.fetch(:minors).last] : entry.fetch(:minors)
2809
- end
2810
- end
2811
- end
2812
-
2813
- def appraisal_matrix_entries(tier1_gems:, tier2_gems: [])
2814
- entries = []
2815
- tier1_gems.each do |tier1|
2816
- tier1_name = tier1[:name] || tier1["name"]
2817
- assignments = tier1[:assignments] || tier1["assignments"] || []
2818
- assignments.each do |assignment|
2819
- tier1_version = assignment[:version] || assignment["version"]
2820
- ruby_series = assignment[:bucket] || assignment["bucket"] || assignment[:ruby_series] || assignment["ruby_series"]
2821
- if tier2_gems.empty?
2822
- entries << appraisal_matrix_entry(
2823
- tier1_name: tier1_name,
2824
- tier1_version: tier1_version,
2825
- ruby_series: ruby_series
2826
- )
2827
- else
2828
- tier2_gems.each do |tier2|
2829
- tier2_name = tier2[:name] || tier2["name"]
2830
- Array(tier2[:versions] || tier2["versions"]).each do |tier2_version|
2831
- entries << appraisal_matrix_entry(
2832
- tier1_name: tier1_name,
2833
- tier1_version: tier1_version,
2834
- ruby_series: ruby_series,
2835
- tier2_name: tier2_name,
2836
- tier2_version: tier2_version
2837
- )
2838
- end
2839
- end
2840
- end
2841
- end
2842
- end
2843
- entries
2844
- end
2845
-
2846
- def appraisal_matrix_entry(tier1_name:, tier1_version:, ruby_series:, tier2_name: nil, tier2_version: nil)
2847
- {
2848
- name: appraisal_name(
2849
- tier1_gem: tier1_name,
2850
- tier1_version: tier1_version,
2851
- tier2_gem: tier2_name,
2852
- tier2_version: tier2_version,
2853
- ruby_series: ruby_series
2854
- ),
2855
- tier1_gemfile: appraisal_modular_gemfile_path(gem_name: tier1_name, version: tier1_version, ruby_series: ruby_series),
2856
- tier2_gemfile: tier2_name ? appraisal_modular_gemfile_path(gem_name: tier2_name, version: tier2_version, ruby_series: ruby_series) : nil,
2857
- x_std_libs_gemfile: File.join("gemfiles", "modular", "x_std_libs", ruby_series.to_s, "libs.gemfile"),
2858
- ruby_series: ruby_series.to_s
2859
- }
2860
- end
2861
-
2862
- def appraisal_filtered_versions(version_metadata, requirements:)
2863
- requirement = requirements ? Gem::Requirement.new(Array(requirements)) : nil
2864
- version_metadata.filter_map do |entry|
2865
- number = entry[:number] || entry["number"]
2866
- next if number.to_s.empty?
2867
- next if entry[:prerelease] || entry["prerelease"]
2868
- next if requirement && !requirement.satisfied_by?(Gem::Version.new(number))
2869
-
2870
- number.to_s
2871
- end.sort_by { |version| Gem::Version.new(version) }
2872
- end
2873
-
2874
- def appraisal_minor_versions_by_major(versions)
2875
- versions.map do |version|
2876
- gem_version = Gem::Version.new(version)
2877
- segments = gem_version.segments
2878
- {
2879
- major: segments[0],
2880
- minor: "#{segments[0]}.#{segments[1] || 0}"
2881
- }
2882
- end.uniq.group_by { |entry| entry.fetch(:major) }.map do |major, entries|
2883
- {
2884
- major: major,
2885
- minors: entries.map { |entry| entry.fetch(:minor) }.sort_by { |minor| Gem::Version.new(minor) }
2886
- }
2887
- end.sort_by { |entry| entry.fetch(:major) }
2888
- end
2889
-
2890
- def appraisal_find_ruby_seams(version_metadata)
2891
- minors = appraisal_latest_patch_by_minor(version_metadata)
2892
- seams = []
2893
- previous = nil
2894
- minors.sort_by { |minor, _entry| Gem::Version.new(minor) }.each do |minor, entry|
2895
- min_ruby = Gem::Version.new((entry[:min_ruby] || entry["min_ruby"]).to_s)
2896
- min_ruby = [min_ruby, DEFAULT_TEST_MINIMUM_RUBY].max
2897
- if previous.nil? || min_ruby > previous
2898
- seams << {version: minor, min_ruby: min_ruby}
2899
- end
2900
- previous = min_ruby
2901
- end
2902
- seams
2903
- end
2904
-
2905
- def appraisal_ruby_series(version_metadata, project_min_ruby: nil)
2906
- floors = appraisal_find_ruby_seams(version_metadata).map { |seam| seam.fetch(:min_ruby) }
2907
- if project_min_ruby
2908
- floor = Gem::Version.new(project_min_ruby.to_s)
2909
- floors.reject! { |version| version < floor }
2910
- floors << floor unless floors.include?(floor)
2911
- end
2912
- floors = [Gem::Version.new("3.2")] if floors.empty?
2913
- appraisal_minor_versions_to_buckets(floors.map { |version| appraisal_minor_key(version) }.uniq.sort)
2914
- end
2915
-
2916
- def appraisal_assign_version_buckets(selected_versions:, seams:, buckets:, bucket_ranges:, all_versions:)
2917
- return [] if selected_versions.empty? || buckets.empty?
2918
-
2919
- normalized_ranges = appraisal_normalized_bucket_ranges(bucket_ranges)
2920
- version_min_rubies = appraisal_version_min_ruby_map(all_versions, seams)
2921
- assignments = selected_versions.sort_by { |version| Gem::Version.new(version) }.filter_map do |version|
2922
- min_ruby = version_min_rubies[version]
2923
- next unless min_ruby
2924
-
2925
- next_seam = appraisal_next_seam_ruby(version, min_ruby, all_versions, version_min_rubies)
2926
- bucket = next_seam ? appraisal_bucket_below(next_seam, buckets, normalized_ranges) : buckets.last
2927
- {version: version, bucket: bucket} if bucket
2928
- end
2929
- appraisal_fill_bucket_gaps(assignments, buckets, normalized_ranges, version_min_rubies, all_versions)
2930
- end
2931
-
2932
- def appraisal_latest_patch_by_minor(version_metadata)
2933
- version_metadata.each_with_object({}) do |entry, latest|
2934
- number = (entry[:number] || entry["number"]).to_s
2935
- next if number.empty?
2936
-
2937
- version = Gem::Version.new(number)
2938
- minor = "#{version.segments[0]}.#{version.segments[1] || 0}"
2939
- current = latest[minor]
2940
- latest[minor] = entry if current.nil? || version > Gem::Version.new((current[:number] || current["number"]).to_s)
2941
- end
2942
- end
2943
-
2944
- def appraisal_minor_versions_to_buckets(minor_versions)
2945
- by_major = minor_versions.group_by { |minor| minor.split(".").first.to_i }
2946
- buckets = []
2947
- ranges = {}
2948
- by_major.each do |major, minors|
2949
- sorted = minors.sort_by { |minor| Gem::Version.new(minor) }
2950
- sorted.each_with_index do |minor, index|
2951
- bucket = (index == sorted.length - 1) ? "r#{major}" : "r#{major}.#{[sorted[index + 1].split(".").last.to_i - 1, minor.split(".").last.to_i].max}"
2952
- next if ranges.key?(bucket)
2953
-
2954
- buckets << bucket
2955
- ceiling = if index == sorted.length - 1
2956
- "#{major}.99"
2957
- else
2958
- bucket.split(".").last ? "#{major}.#{bucket.split(".").last}" : "#{major}.99"
2959
- end
2960
- ranges[bucket] = {floor: Gem::Version.new(minor), ceiling: Gem::Version.new(ceiling)}
2961
- end
2962
- end
2963
- {buckets: buckets.sort_by { |bucket| appraisal_bucket_sort_key(bucket) }, bucket_ranges: ranges}
2964
- end
2965
-
2966
- def appraisal_minor_key(version)
2967
- segments = Gem::Version.new(version.to_s).segments
2968
- "#{segments[0]}.#{segments[1] || 0}"
2969
- end
2970
-
2971
- def appraisal_bucket_sort_key(bucket)
2972
- match = bucket.to_s.match(/\Ar(\d+)(?:\.(\d+))?\z/)
2973
- [match[1].to_i, match[2] ? match[2].to_i : 999]
2974
- end
2975
-
2976
- def appraisal_normalized_bucket_ranges(bucket_ranges)
2977
- bucket_ranges.transform_values do |range|
2978
- {
2979
- floor: Gem::Version.new((range[:floor] || range["floor"]).to_s),
2980
- ceiling: Gem::Version.new((range[:ceiling] || range["ceiling"]).to_s)
2981
- }
2982
- end
2983
- end
2984
-
2985
- def appraisal_version_min_ruby_map(all_versions, seams)
2986
- sorted_seams = seams.sort_by { |seam| Gem::Version.new(seam[:version] || seam["version"]) }
2987
- seam_index = 0
2988
- current = nil
2989
- all_versions.sort_by { |version| Gem::Version.new(version) }.each_with_object({}) do |version, map|
2990
- while seam_index < sorted_seams.length && Gem::Version.new(sorted_seams[seam_index][:version] || sorted_seams[seam_index]["version"]) <= Gem::Version.new(version)
2991
- current = Gem::Version.new((sorted_seams[seam_index][:min_ruby] || sorted_seams[seam_index]["min_ruby"]).to_s)
2992
- current = [current, DEFAULT_TEST_MINIMUM_RUBY].max
2993
- seam_index += 1
2994
- end
2995
- map[version] = current if current
2996
- end
2997
- end
2998
-
2999
- def appraisal_next_seam_ruby(version, min_ruby, all_versions, version_min_rubies)
3000
- found = false
3001
- all_versions.sort_by { |candidate| Gem::Version.new(candidate) }.each do |candidate|
3002
- found ||= Gem::Version.new(candidate) >= Gem::Version.new(version)
3003
- next unless found
3004
-
3005
- candidate_ruby = version_min_rubies[candidate]
3006
- return candidate_ruby if candidate_ruby && candidate_ruby > min_ruby
3007
- end
3008
- nil
3009
- end
3010
-
3011
- def appraisal_bucket_below(ruby_floor, buckets, bucket_ranges)
3012
- buckets.filter_map do |bucket|
3013
- range = bucket_ranges[bucket]
3014
- next unless range && range.fetch(:ceiling) < ruby_floor
3015
-
3016
- [bucket, range.fetch(:ceiling)]
3017
- end.max_by(&:last)&.first
3018
- end
3019
-
3020
- def appraisal_fill_bucket_gaps(assignments, buckets, bucket_ranges, version_min_rubies, all_versions)
3021
- covered = assignments.map { |assignment| assignment.fetch(:bucket) }
3022
- (buckets - covered).each do |bucket|
3023
- range = bucket_ranges[bucket]
3024
- next unless range
3025
-
3026
- filler = all_versions.sort_by { |version| Gem::Version.new(version) }.reverse.find do |version|
3027
- ruby = version_min_rubies[version]
3028
- ruby&.between?(range.fetch(:floor), range.fetch(:ceiling))
3029
- end
3030
- filler ||= all_versions.sort_by { |version| Gem::Version.new(version) }.reverse.find do |version|
3031
- ruby = version_min_rubies[version]
3032
- ruby && ruby <= range.fetch(:ceiling)
3033
- end
3034
- assignments << {version: filler, bucket: bucket, filler: true} if filler
3035
- end
3036
- assignments.sort_by { |assignment| bucket_ranges.fetch(assignment.fetch(:bucket)).fetch(:floor) }
3037
- end
3038
-
3039
- def appraisal_resolve_sub_dependencies(parent_gem:, parent_version:, parent_versions:, dependency_versions:, ruby_min: nil, excluded_gems: [])
3040
- parent = appraisal_latest_version_matching(parent_versions, parent_version)
3041
- return {} unless parent
3042
-
3043
- ruby_floor = Gem::Version.new(ruby_min.to_s) unless ruby_min.to_s.empty?
3044
- excluded = excluded_gems.map(&:to_s)
3045
- Array(parent[:runtime_dependencies] || parent["runtime_dependencies"]).each_with_object({}) do |dependency, resolved|
3046
- name = (dependency[:name] || dependency["name"]).to_s
3047
- next if name.empty? || excluded.include?(name)
3048
-
3049
- requirement = begin
3050
- Gem::Requirement.new(dependency[:requirements] || dependency["requirements"] || ">= 0")
3051
- rescue ArgumentError
3052
- Gem::Requirement.default
3053
- end
3054
- selected = appraisal_select_dependency_version(
3055
- Array(dependency_versions[name] || dependency_versions[name.to_sym]),
3056
- requirement: requirement,
3057
- ruby_min: ruby_floor
3058
- )
3059
- resolved[name] = selected if selected
3060
- end
3061
- end
3062
-
3063
- def appraisal_latest_version_matching(version_metadata, requested_version)
3064
- prefix = "#{requested_version}."
3065
- version_metadata.select do |entry|
3066
- number = (entry[:number] || entry["number"]).to_s
3067
- number == requested_version.to_s || number.start_with?(prefix)
3068
- end.max_by { |entry| Gem::Version.new((entry[:number] || entry["number"]).to_s) }
3069
- end
3070
-
3071
- def appraisal_select_dependency_version(version_metadata, requirement:, ruby_min:)
3072
- compatible = version_metadata.select do |entry|
3073
- number = (entry[:number] || entry["number"]).to_s
3074
- !number.empty? && requirement.satisfied_by?(Gem::Version.new(number))
3075
- end.sort_by { |entry| Gem::Version.new((entry[:number] || entry["number"]).to_s) }
3076
- return if compatible.empty?
3077
-
3078
- if ruby_min
3079
- selected = compatible.reverse.find do |entry|
3080
- min_ruby = entry[:min_ruby] || entry["min_ruby"]
3081
- min_ruby.to_s.empty? || Gem::Version.new(min_ruby.to_s) <= ruby_min
3082
- end
3083
- return (selected || compatible.first).then { |entry| entry[:number] || entry["number"] }
3084
- end
3085
- compatible.last.then { |entry| entry[:number] || entry["number"] }
3086
- end
3087
-
3088
- def appraisal_stale_gemfile_paths(existing_paths:, current_entries:)
3089
- current_names = current_entries.map { |entry| (entry[:name] || entry["name"]).to_s }.to_set
3090
- existing_paths.map(&:to_s).select do |path|
3091
- basename = File.basename(path, ".gemfile")
3092
- path.start_with?("gemfiles/#{APPRAISAL_NAME_PREFIX}-") &&
3093
- path.end_with?(".gemfile") &&
3094
- !current_names.include?(basename)
3095
- end.sort
3096
- end
3097
-
3098
- def appraisal_extract_runtime_dependencies(gemspec_content)
3099
- gemspec_dependency_records(gemspec_content)
3100
- .reject { |record| record.fetch(:kind) == "add_development_dependency" }
3101
- .map { |record| record.fetch(:name) }
3102
- .uniq
3103
- end
3104
-
3105
- def appraisal_scaffold_config(gemspec_content:, existing_config: {}, exclusions: [], default_mode: "semver", freshness_ttl: APPRAISAL_DEFAULT_FRESHNESS_TTL)
3106
- excluded = exclusions.map(&:to_s).to_set
3107
- runtime_dependencies = appraisal_extract_runtime_dependencies(gemspec_content)
3108
- tier1 = runtime_dependencies.reject { |name| excluded.include?(name) }.map { |name| {"name" => name} }
3109
- config = deep_string_key_hash(existing_config)
3110
- matrix = config["appraisal_matrix"] || {}
3111
- gems = matrix["gems"] || {}
3112
-
3113
- matrix["mode"] ||= default_mode
3114
- matrix["freshness_ttl"] ||= freshness_ttl
3115
- gems["tier1"] = tier1
3116
- gems["tier2"] ||= []
3117
- matrix["gems"] = gems
3118
- config["appraisal_matrix"] = matrix
3119
- config
3120
- end
3121
-
3122
- def appraisal_matrix_has_versions?(matrix)
3123
- gems = deep_string_key_hash(matrix || {}).fetch("gems", {})
3124
- %w[tier1 tier2].any? do |tier|
3125
- Array(gems[tier]).any? { |gem_config| Array(gem_config["versions"]).any? }
3126
- end
3127
- end
3128
-
3129
- def appraisal_matrix_fresh?(matrix, now: Time.now.to_i)
3130
- resolved_at = (matrix || {})[:resolved_at] || (matrix || {})["resolved_at"]
3131
- return false unless resolved_at
3132
-
3133
- ttl = (matrix || {})[:freshness_ttl] || (matrix || {})["freshness_ttl"] || APPRAISAL_DEFAULT_FRESHNESS_TTL
3134
- (now.to_i - resolved_at.to_i) < ttl.to_i
3135
- end
3136
-
3137
- def appraisal_time_ago(timestamp, now: Time.now.to_i)
3138
- return "unknown" unless timestamp
3139
-
3140
- seconds = now.to_i - timestamp.to_i
3141
- return "#{seconds / 60}m" if seconds < 3600
3142
- return "#{seconds / 3600}h" if seconds < 86_400
3143
-
3144
- "#{seconds / 86_400}d"
3145
- end
3146
-
3147
- def appraisal_all_versions_for(resolver:, gem_name:, mode:, requirements: nil, include_versions: nil, exclude_versions: nil)
3148
- base_versions = if mode.to_s == "patch"
3149
- resolver.versions(gem_name, requirements: requirements).map { |entry| entry[:number] || entry["number"] }
3150
- else
3151
- resolver.minor_versions_by_major(gem_name, requirements: requirements).flat_map { |entry| entry[:minors] || entry["minors"] }
3152
- end
3153
- appraisal_finalize_versions(base_versions, include_versions: include_versions, exclude_versions: exclude_versions)
3154
- end
3155
-
3156
- def appraisal_finalize_versions(base_versions, include_versions: nil, exclude_versions: nil)
3157
- merged = appraisal_sort_versions(Array(base_versions) + Array(include_versions))
3158
- excluded = Array(exclude_versions).map(&:to_s).to_set
3159
- return merged if excluded.empty?
3160
-
3161
- appraisal_sort_versions(merged.reject { |version| excluded.include?(version) })
3162
- end
3163
-
3164
- def appraisal_compatible_version_for_bucket?(resolver:, gem_name:, version:, ruby_series:, bucket_ranges:)
3165
- range = bucket_ranges[ruby_series] || bucket_ranges[ruby_series.to_s]
3166
- return true unless range
3167
-
3168
- ceiling = Gem::Version.new((range[:ceiling] || range["ceiling"]).to_s)
3169
- exact_version = appraisal_latest_minor_patch(resolver: resolver, gem_name: gem_name, version: version)
3170
- min_ruby = resolver.min_ruby_version(gem_name, exact_version)
3171
- min_ruby.nil? || Gem::Version.new(min_ruby.to_s) <= ceiling
3172
- rescue
3173
- true
3174
- end
3175
-
3176
- def appraisal_latest_minor_patch(resolver:, gem_name:, version:)
3177
- all_versions = resolver.versions(gem_name)
3178
- prefix = "#{version}."
3179
- matching = all_versions.select do |entry|
3180
- number = (entry[:number] || entry["number"]).to_s
3181
- number == version.to_s || number.start_with?(prefix)
3182
- end
3183
- return version.to_s if matching.empty?
3184
-
3185
- latest = matching.max_by { |entry| Gem::Version.new((entry[:number] || entry["number"]).to_s) }
3186
- (latest[:number] || latest["number"]).to_s
3187
- end
3188
-
3189
- def appraisal_sort_versions(values)
3190
- values.compact.map(&:to_s).reject(&:empty?).uniq.sort_by { |version| Gem::Version.new(version) }
3191
- end
3192
-
3193
2781
  def deep_string_key_hash(value)
3194
2782
  case value
3195
2783
  when Hash
@@ -3274,6 +2862,7 @@ module Kettle
3274
2862
  }
3275
2863
  bootstrap = kettle_config_bootstrap_facts(project_root, env, template_selection: template_selection)
3276
2864
  bootstrap[:licenses] = configured_or_detected_licenses if bootstrap && !configured_or_detected_licenses.empty?
2865
+ bootstrap[:platforms] = detected_os_platforms(project_root) if bootstrap && !detected_os_platforms(project_root).empty?
3277
2866
  facts[:kettle_config_bootstrap] = bootstrap if bootstrap
3278
2867
  facts[:author] = author unless author.empty?
3279
2868
  facts[:copyright] = copyright unless copyright.empty?
@@ -3521,12 +3110,21 @@ module Kettle
3521
3110
  facts[:version_gem] = version_gem_facts unless version_gem_facts.empty?
3522
3111
  facts[:shim] = shim unless shim.empty?
3523
3112
  facts[:repository] = repository unless repository.empty?
3524
- generated_blocks = generated_blocks_facts(gemspec_metadata, facts.merge(project_root: File.expand_path(project_root)), run_options)
3113
+ # facts[:ci] itself isn't assembled until later in this method, but
3114
+ # shunted_gemfile_block needs the project's effective CI test floor
3115
+ # (already resolved into project_runtime, above) rather than the
3116
+ # gemspec's own, often much older, required_ruby_version.
3117
+ generated_blocks = generated_blocks_facts(
3118
+ gemspec_metadata,
3119
+ facts.merge(project_root: File.expand_path(project_root), ci: {test_min_ruby: project_runtime.fetch(:test_min_ruby).to_s}),
3120
+ run_options
3121
+ )
3525
3122
  facts[:generated_blocks] = generated_blocks unless generated_blocks.empty?
3526
3123
  bootstrap = kettle_config_bootstrap_facts(project_root, env, template_selection: template_selection)
3527
3124
  if bootstrap
3528
3125
  min_ruby_token = minimum_ruby_token(min_ruby)
3529
3126
  bootstrap[:licenses] = gemspec_license_spdx unless gemspec_license_spdx.empty?
3127
+ bootstrap[:platforms] = detected_os_platforms(project_root) unless detected_os_platforms(project_root).empty?
3530
3128
  bootstrap[:gemspec_path] = File.basename(gemspec_path) if gemspec_path
3531
3129
  bootstrap[:min_ruby] = min_ruby_token unless min_ruby_token.empty?
3532
3130
  bootstrap[:test_min_ruby] = config_test_min_ruby(kettle_config, min_ruby).to_s
@@ -3593,6 +3191,7 @@ module Kettle
3593
3191
  default_branch: "main",
3594
3192
  exec_cmd: github_actions_exec_cmd(kettle_config, env),
3595
3193
  engine_exec_cmds: github_actions_engine_exec_cmds(kettle_config),
3194
+ platforms: ci_os_platforms_facts(kettle_config, project_root),
3596
3195
  recording: project_recording_enabled?(project_root, kettle_config),
3597
3196
  ruby_versions: github_actions_ruby_versions(project_runtime.fetch(:test_min_ruby)),
3598
3197
  test_min_ruby: project_runtime.fetch(:test_min_ruby).to_s,
@@ -3882,7 +3481,7 @@ module Kettle
3882
3481
  dependencies = extract_gemspec_development_dependencies(gemspec)
3883
3482
  return if dependencies.empty?
3884
3483
 
3885
- floor = shunted_effective_floor(facts.dig(:rubygems, :min_ruby))
3484
+ floor = shunted_effective_floor(facts.dig(:ci, :test_min_ruby) || facts.dig(:rubygems, :min_ruby))
3886
3485
  project_root = facts[:project_root] || facts["project_root"] || run_options[:project_root] || run_options["project_root"]
3887
3486
  shunted = dependencies.filter_map do |dependency|
3888
3487
  next if shunted_dependency_has_modular_override?(project_root, dependency)
@@ -3951,11 +3550,11 @@ module Kettle
3951
3550
 
3952
3551
  def shunted_effective_floor(min_ruby)
3953
3552
  floor = minimum_ruby_token(min_ruby)
3954
- versions = [Gem::Version.new("2.3")]
3553
+ versions = [DEFAULT_TEST_MINIMUM_RUBY]
3955
3554
  versions << Gem::Version.new(floor) unless floor.to_s.empty?
3956
3555
  versions.max
3957
3556
  rescue ArgumentError
3958
- Gem::Version.new("2.3")
3557
+ DEFAULT_TEST_MINIMUM_RUBY
3959
3558
  end
3960
3559
 
3961
3560
  def shunted_gemfile_managed_block(dependencies)
@@ -3978,6 +3577,7 @@ module Kettle
3978
3577
  events = event_stream_from_options(run_options)
3979
3578
  emit_event(events, "run_start", mode: "plan", project_root: project_root.to_s)
3980
3579
  with_event_phase(events, "runtime_dependencies") { ensure_runtime_dependencies! }
3580
+ with_event_phase(events, "tslp_prefetch") { prefetch_tslp_grammars(env: env, events: events) }
3981
3581
  with_event_phase(events, "preflight") { preflight_project!(project_root) }
3982
3582
  template_selection = with_event_phase(events, "template_selection") { template_selection_for(env, run_options) }
3983
3583
  checksum_mode = with_event_phase(events, "checksum_mode") { checksum_mode_for(env, run_options) }
@@ -4194,6 +3794,10 @@ module Kettle
4194
3794
  # Config migrations are implemented during template application. They must
4195
3795
  # run even when the template source and destination checksums still match.
4196
3796
  return false if report.fetch(:relative_path, "").to_s == KETTLE_CONFIG_PATH
3797
+ # Appraisal.root.gemfile has facts-dependent structural normalization in
3798
+ # its accepted-template finalizer, so matching source/destination hashes
3799
+ # do not prove that the final output is already normalized.
3800
+ return false if report.fetch(:relative_path, "").to_s == "Appraisal.root.gemfile"
4197
3801
 
4198
3802
  primitive = report.dig(:request_envelope, :request, :recipe_name).to_s
4199
3803
  primitive == "supplied_template_source_application"
@@ -4227,6 +3831,12 @@ module Kettle
4227
3831
  if template_file_type_for_relative_path(report.fetch(:relative_path).to_s) == :appraisals
4228
3832
  payload[:appraisals_template_policy_fingerprint_version] = APPRAISALS_TEMPLATE_POLICY_FINGERPRINT_VERSION
4229
3833
  end
3834
+ if report.fetch(:relative_path).to_s.start_with?("gemfiles/modular/")
3835
+ conflict_policy = modular_dependency_conflict_decisions(
3836
+ kettle_jem_config(project_root)
3837
+ ).select { |decision| decision.fetch("modular") == report.fetch(:relative_path).to_s }
3838
+ payload[:modular_dependency_conflict_policy] = conflict_policy
3839
+ end
4230
3840
  if template_file_type_for_relative_path(report.fetch(:relative_path).to_s) == :gemspec
4231
3841
  payload[:gemspec_template_policy_fingerprint_version] = GEMSPEC_TEMPLATE_POLICY_FINGERPRINT_VERSION
4232
3842
  end
@@ -4721,19 +4331,11 @@ module Kettle
4721
4331
  report[:changed_files] = (report.fetch(:changed_files, []) + report.fetch(:post_apply_steps).flat_map do |step|
4722
4332
  reported_post_apply_changed_files(step)
4723
4333
  end).uniq.sort
4724
- report[:duplicate_drift] = with_event_phase(events, "duplicate_drift") do
4725
- if DecisionPolicy.value_to_boolean((run_options || {})[:skip_drift_check])
4726
- {
4727
- available: false,
4728
- skipped: true,
4729
- reason: "skip_drift_check"
4730
- }
4731
- else
4732
- duplicate_drift_report(
4733
- project_root: project_root,
4734
- template_root: template_root_path(project_root, config: kettle_jem_config(project_root)),
4735
- run_options: run_options
4736
- )
4334
+ report[:duplicate_drift] = if DecisionPolicy.value_to_boolean((run_options || {})[:defer_drift_check])
4335
+ {available: false, skipped: true, reason: "deferred_until_postprocessing"}
4336
+ else
4337
+ with_event_phase(events, "duplicate_drift") do
4338
+ duplicate_drift_check_result(project_root: project_root, run_options: run_options)
4737
4339
  end
4738
4340
  end
4739
4341
  report[:phase_timings] = events.phase_timings if events.respond_to?(:phase_timings)
@@ -4741,6 +4343,25 @@ module Kettle
4741
4343
  report
4742
4344
  end
4743
4345
 
4346
+ def finalize_duplicate_drift(project_root:, report:, run_options: {})
4347
+ events = event_stream_from_options(run_options)
4348
+ drift = with_event_phase(events, "duplicate_drift") do
4349
+ duplicate_drift_check_result(project_root: project_root, run_options: run_options)
4350
+ end
4351
+ report.merge(duplicate_drift: drift, phase_timings: events.phase_timings)
4352
+ end
4353
+
4354
+ def duplicate_drift_check_result(project_root:, run_options: {})
4355
+ return {available: false, skipped: true, reason: "skip_drift_check"} if
4356
+ DecisionPolicy.value_to_boolean((run_options || {})[:skip_drift_check])
4357
+
4358
+ duplicate_drift_report(
4359
+ project_root: project_root,
4360
+ template_root: template_root_path(project_root, config: kettle_jem_config(project_root)),
4361
+ run_options: run_options
4362
+ )
4363
+ end
4364
+
4744
4365
  def event_stream(io, types: nil)
4745
4366
  Kettle::Ndjson.event_stream(
4746
4367
  io,
@@ -5483,6 +5104,7 @@ module Kettle
5483
5104
  ensure_trailing_newline(content.to_s.empty? ? %(source "https://gem.coop"\n) : content.to_s).dup
5484
5105
  )
5485
5106
  updated = remove_gemfile_dependency_blocks(updated, PROHIBITED_GEMFILE_DEPENDENCIES)
5107
+ updated = remove_gemfile_builtin_git_source_overrides(updated).dup
5486
5108
  monorepo_root_gemfile_dependency_lines.each do |line|
5487
5109
  next if gemfile_declares_gem?(updated, line.fetch(:name))
5488
5110
 
@@ -5494,17 +5116,17 @@ module Kettle
5494
5116
 
5495
5117
  def monorepo_root_gemfile_dependency_lines
5496
5118
  [
5497
- {name: "appraisal2", source: %(gem "appraisal2", "~> 3.2", ">= 3.2.3"\n)},
5119
+ {name: "appraisal2", source: %(gem "appraisal2", "~> 3.2", ">= 3.2.5"\n)},
5498
5120
  {name: "bundler-audit", source: %(gem "bundler-audit", "~> 0.9.3"\n)},
5499
- {name: "kettle-dev", source: %(gem "kettle-dev", "~> 3.1", ">= 3.1.0"\n)},
5500
- {name: "kettle-drift", source: %(gem "kettle-drift", "~> 1.0", ">= 1.0.13"\n)},
5501
- {name: "kettle-family", source: %(gem "kettle-family", "~> 1.3", ">= 1.3.1"\n)},
5121
+ {name: "kettle-dev", source: %(gem "kettle-dev", "~> 3.1", ">= 3.1.13"\n)},
5122
+ {name: "kettle-drift", source: %(gem "kettle-drift", "~> 1.0", ">= 1.0.16"\n)},
5123
+ {name: "kettle-family", source: %(gem "kettle-family", "~> 1.3", ">= 1.3.10"\n)},
5502
5124
  {name: PACKAGE_NAME, source: kettle_jem_dependency_source},
5503
- {name: "kettle-test", source: %(gem "kettle-test", "~> 2.0", ">= 2.0.21"\n)},
5125
+ {name: "kettle-test", source: %(gem "kettle-test", "~> 2.0", ">= 2.0.25"\n)},
5504
5126
  {name: "rake", source: %(gem "rake", "~> 13.0"\n)},
5505
5127
  {name: "rspec", source: %(gem "rspec", "~> 3.0"\n)},
5506
- {name: "stone_checksums", source: %(gem "stone_checksums", "~> 1.0", ">= 1.0.8"\n)},
5507
- {name: "turbo_tests2", source: %(gem "turbo_tests2", "~> 3.2", ">= 3.2.7"\n)}
5128
+ {name: "stone_checksums", source: %(gem "stone_checksums", "~> 1.0", ">= 1.0.9"\n)},
5129
+ {name: "turbo_tests2", source: %(gem "turbo_tests2", "~> 4.0", ">= 4.0.0"\n)}
5508
5130
  ].freeze
5509
5131
  end
5510
5132
 
@@ -5546,6 +5168,24 @@ module Kettle
5546
5168
  end.sort
5547
5169
  end
5548
5170
 
5171
+ # CI OS families evidenced by the destination's existing workflows. The
5172
+ # heuristic exists so re-templating a repo that already runs Windows CI
5173
+ # does not silently drop that coverage when the `platforms:` key is
5174
+ # injected or generated from. It only ever ADDS windows to the default:
5175
+ # the key's absent-means-default semantics already cover linux and macos,
5176
+ # and every packaged workflow is Linux-only except current.yml, which the
5177
+ # template always ships with ubuntu and macos lanes. Returns [] when the
5178
+ # destination has no workflow directory, so callers fall back cleanly.
5179
+ def detected_os_platforms(project_root)
5180
+ workflows_dir = File.join(project_root.to_s, ".github", "workflows")
5181
+ return [] unless Dir.exist?(workflows_dir)
5182
+
5183
+ windows = Dir.glob(File.join(workflows_dir, "*.{yml,yaml}")).any? do |path|
5184
+ File.read(path).match?(WINDOWS_RUNNER_LABEL_PATTERN)
5185
+ end
5186
+ windows ? DEFAULT_PLATFORMS + ["windows"] : DEFAULT_PLATFORMS.dup
5187
+ end
5188
+
5549
5189
  def spdx_from_basename(basename)
5550
5190
  return "LicenseRef-Big-Time-Public-License" if basename.to_s == "Big-Time-Public-License"
5551
5191
 
@@ -5985,6 +5625,7 @@ module Kettle
5985
5625
  def readme_template_tokens(facts)
5986
5626
  {
5987
5627
  "KJ|CB:USER" => "",
5628
+ "KJ|DEFAULT_TEST_MINIMUM_RUBY" => DEFAULT_TEST_MINIMUM_RUBY.to_s,
5988
5629
  "KJ|FUNDING:BUYMEACOFFEE" => "",
5989
5630
  "KJ|FUNDING:KOFI" => "",
5990
5631
  "KJ|FUNDING:LIBERAPAY" => "",
@@ -6960,7 +6601,7 @@ module Kettle
6960
6601
  def gemfile_policy_operations(template_content, original, final, request)
6961
6602
  package_name = runtime_context_value(request, :package, :name).to_s
6962
6603
  deleted = gemfile_dependency_names("#{template_content}\n#{original}") - gemfile_dependency_names(final)
6963
- expected = GEMFILE_POLICY_SELF_DEPENDENCIES.dup
6604
+ expected = PROHIBITED_GEMFILE_DEPENDENCIES.dup
6964
6605
  expected << package_name unless package_name.empty?
6965
6606
  [
6966
6607
  {
@@ -7213,7 +6854,11 @@ module Kettle
7213
6854
  destination_content: original
7214
6855
  )
7215
6856
  return with_readme_timing("readme.append_used_link_definitions") do
7216
- appended = append_used_markdown_link_definitions(processed, resolved)
6857
+ appended = append_used_markdown_link_definitions(
6858
+ processed,
6859
+ resolved,
6860
+ fallback_definition_source: original
6861
+ )
7217
6862
  postprocess_readme_content(
7218
6863
  appended,
7219
6864
  facts,
@@ -7225,7 +6870,7 @@ module Kettle
7225
6870
  if strategy.empty? || strategy == "merge"
7226
6871
  merged = merge_config_template_source(recipe, resolved, original, facts: facts, env: env)
7227
6872
  merged = preserve_mise_project_settings(recipe, merged, original, project_root: project_root, facts: facts) if template_file_type(recipe) == :toml
7228
- return finalize_template_source_content(recipe, sync_kettle_config_env_overrides(merged, env)) if recipe.fetch(:target_path) == KETTLE_CONFIG_PATH
6873
+ return finalize_template_source_content(recipe, sync_kettle_config_env_overrides(merged, env, project_root: project_root)) if recipe.fetch(:target_path) == KETTLE_CONFIG_PATH
7229
6874
 
7230
6875
  merged = postprocess_funding_markdown_content(merged, facts) if recipe.fetch(:target_path) == "FUNDING.md"
7231
6876
 
@@ -7240,7 +6885,7 @@ module Kettle
7240
6885
  if strategy == "accept_template"
7241
6886
  accepted = finalize_accepted_template_source(recipe, resolved, original, facts: facts, project_root: project_root)
7242
6887
  accepted = preserve_github_workflow_project_settings(recipe, accepted, original, project_root: project_root) if github_workflow_template_recipe?(recipe)
7243
- accepted = sync_kettle_config_env_overrides(accepted, env) if recipe.fetch(:target_path) == KETTLE_CONFIG_PATH
6888
+ accepted = sync_kettle_config_env_overrides(accepted, env, project_root: project_root) if recipe.fetch(:target_path) == KETTLE_CONFIG_PATH
7244
6889
  accepted = postprocess_funding_markdown_content(accepted, facts) if recipe.fetch(:target_path) == "FUNDING.md"
7245
6890
  if github_workflow_template_recipe?(recipe)
7246
6891
  accepted = finalize_github_workflow_template(accepted, facts)
@@ -7297,7 +6942,12 @@ module Kettle
7297
6942
  when :gemfile
7298
6943
  finalize_gemfile_template_source(recipe, content, destination_content, facts: facts, template_content: content)
7299
6944
  when :appraisals
7300
- merge_appraisals_template_policy(content, facts: facts)
6945
+ merged = merge_appraisals_template_policy(content, facts: facts)
6946
+ if recipe.fetch(:target_path).to_s == "Appraisal.root.gemfile"
6947
+ normalize_appraisal_root_templating_gate(merged, facts)
6948
+ else
6949
+ merged
6950
+ end
7301
6951
  when :gemspec
7302
6952
  package_name = facts.dig(:package, :name).to_s if facts
7303
6953
  receiver = gemspec_block_param(content) || "spec"
@@ -7788,12 +7438,14 @@ module Kettle
7788
7438
  [content.to_s.rstrip, "", missing_sources.join.rstrip, ""].join("\n")
7789
7439
  end
7790
7440
 
7791
- def append_used_markdown_link_definitions(content, definition_source)
7441
+ def append_used_markdown_link_definitions(content, definition_source, fallback_definition_source: nil)
7792
7442
  owners = ReadmePostProcessor.markdown_structural_owners(content, :link_definitions, :inline_references)
7793
7443
  existing = owners.fetch(:link_definitions).map { |owner| owner.label.to_s }.to_set
7794
7444
  referenced = owners.fetch(:inline_references).flat_map(&:labels).map(&:to_s).to_set
7795
- available = ReadmePostProcessor.markdown_link_definition_owners(definition_source).to_h do |owner|
7796
- [owner.label.to_s, owner]
7445
+ available = [fallback_definition_source, definition_source].compact.each_with_object({}) do |source, definitions|
7446
+ ReadmePostProcessor.markdown_link_definition_owners(source).each do |owner|
7447
+ definitions[owner.label.to_s] = owner
7448
+ end
7797
7449
  end
7798
7450
  missing = referenced.filter_map do |label|
7799
7451
  next if existing.include?(label)
@@ -8075,6 +7727,10 @@ module Kettle
8075
7727
  preserve_self_word_entries: local_gemfile_template_recipe?(recipe)
8076
7728
  )
8077
7729
  end
7730
+ output = remove_gemfile_builtin_git_source_overrides(output)
7731
+ if recipe.fetch(:target_path).to_s == "Appraisal.root.gemfile"
7732
+ output = normalize_appraisal_root_templating_gate(output, facts)
7733
+ end
8078
7734
  if recipe.fetch(:target_path).to_s == "Gemfile"
8079
7735
  # A merged Gemfile retains its project-specific source; an accepted
8080
7736
  # template owns the complete source declaration.
@@ -8243,27 +7899,147 @@ module Kettle
8243
7899
  block_ifs.drop(1).each do |block_if|
8244
7900
  records << {start_line: block_if.location.start_line, end_line: block_if.location.end_line}
8245
7901
  end
8246
- end
8247
- end
7902
+ end
7903
+ end
7904
+
7905
+ def guard_main_gemfile_runtime_workspace_overrides(content)
7906
+ content = collapse_nested_templating_guards(content)
7907
+ content = normalize_templating_guard_indentation(content)
7908
+ records = runtime_workspace_override_records(content)
7909
+ return content if records.empty?
7910
+
7911
+ output = content.to_s
7912
+ records.sort_by { |record| -record.fetch(:start_line) }.each do |record|
7913
+ source = output.lines[(record.fetch(:start_line) - 1)..(record.fetch(:end_line) - 1)].join
7914
+ guarded = [
7915
+ %(unless ENV.fetch("K_JEM_TEMPLATING", "false").casecmp("true").zero?),
7916
+ indent_source(source, 2).rstrip,
7917
+ "end",
7918
+ ""
7919
+ ].join("\n")
7920
+ output = replace_source_range_lines(output, record.fetch(:start_line), record.fetch(:end_line), guarded)
7921
+ end
7922
+ output
7923
+ end
7924
+
7925
+ def normalize_appraisal_root_templating_gate(content, facts)
7926
+ has_structuredmerge_dependency = package_runtime_dependency_names(facts).any? do |name|
7927
+ STRUCTUREDMERGE_GEMS.include?(name.to_s)
7928
+ end
7929
+ result = prism_parse_success(content)
7930
+ return content unless result
7931
+
7932
+ nodes = []
7933
+ result.value.breadth_first_search_all { |node| nodes << node }
7934
+ template_eval = nodes.find do |node|
7935
+ node.is_a?(::Prism::CallNode) &&
7936
+ node.name == :eval_gemfile &&
7937
+ ruby_string_argument(node) == "gemfiles/modular/templating.gemfile"
7938
+ end
7939
+ template_gate = nodes.find do |node|
7940
+ node.is_a?(::Prism::IfNode) &&
7941
+ node.location.slice.to_s.include?("K_JEM_TEMPLATING") &&
7942
+ template_eval &&
7943
+ template_eval.location.start_offset > node.location.start_offset &&
7944
+ template_eval.location.end_offset < node.location.end_offset
7945
+ end
7946
+ generator_only = nodes.find do |node|
7947
+ node.is_a?(::Prism::CallNode) && node.name == :generator_only && node.block
7948
+ end
7949
+ style_comment = "# The style toolchain includes dependencies that require Ruby 3.3 or newer."
7950
+ style_gate_anchor = nodes.find do |node|
7951
+ gemfile_conditional_node?(node) && node.location.slice.to_s.include?("Gem::Version")
7952
+ end
7953
+ style_comment_attached = if style_gate_anchor
7954
+ content.lines[style_gate_anchor.location.start_line - 2].to_s.strip == style_comment
7955
+ else
7956
+ false
7957
+ end
7958
+
7959
+ if has_structuredmerge_dependency
7960
+ template_gate_is_nested = template_gate && generator_only &&
7961
+ template_gate.location.start_offset > generator_only.block.location.start_offset &&
7962
+ template_gate.location.end_offset < generator_only.block.location.end_offset
7963
+ if template_gate_is_nested
7964
+ return content if style_comment_attached
7965
+
7966
+ comment_line_index = content.lines.index { |line| line.strip == style_comment }
7967
+ return content unless comment_line_index
7968
+
7969
+ content = replace_source_range_lines(content, comment_line_index + 1, comment_line_index + 1, "")
7970
+ result = prism_parse_success(content)
7971
+ nodes = []
7972
+ result&.value&.breadth_first_search_all { |node| nodes << node }
7973
+ anchor = nodes.find do |node|
7974
+ gemfile_conditional_node?(node) && node.location.slice.to_s.include?("Gem::Version")
7975
+ end
7976
+ return content unless anchor
7977
+
7978
+ return insert_lines_before(content, anchor.location.start_line, "#{style_comment}\n")
7979
+ end
7980
+
7981
+ if template_gate
7982
+ content = replace_source_range_lines(
7983
+ content,
7984
+ template_gate.location.start_line,
7985
+ expand_line_range_through_following_blanks(content, ruby_node_source_end_line(template_gate)),
7986
+ ""
7987
+ )
7988
+ elsif template_eval
7989
+ content = replace_source_range_lines(
7990
+ content,
7991
+ template_eval.location.start_line,
7992
+ ruby_node_source_end_line(template_eval),
7993
+ ""
7994
+ )
7995
+ end
7996
+
7997
+ result = prism_parse_success(content)
7998
+ return content unless result
7999
+
8000
+ nodes = []
8001
+ result.value.breadth_first_search_all { |node| nodes << node }
8002
+ gate = <<~RUBY
8003
+ if respond_to?(:generator_only)
8004
+ generator_only do
8005
+ if ENV.fetch("K_JEM_TEMPLATING", "false").casecmp("true").zero?
8006
+ eval_gemfile "gemfiles/modular/templating.gemfile"
8007
+ end
8008
+ end
8009
+ end
8010
+ RUBY
8011
+ anchor = nodes.find do |node|
8012
+ gemfile_conditional_node?(node) && node.location.slice.to_s.include?("Gem::Version")
8013
+ end
8014
+ return content unless anchor
8248
8015
 
8249
- def guard_main_gemfile_runtime_workspace_overrides(content)
8250
- content = collapse_nested_templating_guards(content)
8251
- content = normalize_templating_guard_indentation(content)
8252
- records = runtime_workspace_override_records(content)
8253
- return content if records.empty?
8016
+ lines = content.lines
8017
+ anchor_line_index = anchor.location.start_line - 1
8018
+ preceding_line = lines[anchor_line_index - 1]
8019
+ if preceding_line&.strip == style_comment
8020
+ comment_start = lines.take(anchor_line_index - 1).sum(&:bytesize)
8021
+ replace_source_offsets(content, [{
8022
+ start_offset: comment_start,
8023
+ end_offset: anchor.location.start_offset,
8024
+ replacement: "#{gate}#{preceding_line}"
8025
+ }])
8026
+ else
8027
+ replace_source_offsets(content, [{
8028
+ start_offset: anchor.location.start_offset,
8029
+ end_offset: anchor.location.start_offset,
8030
+ replacement: gate
8031
+ }])
8032
+ end
8033
+ else
8034
+ return content unless template_gate
8254
8035
 
8255
- output = content.to_s
8256
- records.sort_by { |record| -record.fetch(:start_line) }.each do |record|
8257
- source = output.lines[(record.fetch(:start_line) - 1)..(record.fetch(:end_line) - 1)].join
8258
- guarded = [
8259
- %(unless ENV.fetch("K_JEM_TEMPLATING", "false").casecmp("true").zero?),
8260
- indent_source(source, 2).rstrip,
8261
- "end",
8036
+ replace_source_range_lines(
8037
+ content,
8038
+ template_gate.location.start_line,
8039
+ expand_line_range_through_following_blanks(content, ruby_node_source_end_line(template_gate)),
8262
8040
  ""
8263
- ].join("\n")
8264
- output = replace_source_range_lines(output, record.fetch(:start_line), record.fetch(:end_line), guarded)
8041
+ )
8265
8042
  end
8266
- output
8267
8043
  end
8268
8044
 
8269
8045
  def ensure_main_gemfile_nomono_bootstrap(content, template_content)
@@ -8976,6 +8752,19 @@ module Kettle
8976
8752
  def normalize_local_gemfile_nomono_bootstrap(content)
8977
8753
  output = remove_obsolete_local_gemfile_nomono_activation(content)
8978
8754
  bootstrap = "#{local_gemfile_nomono_bootstrap(nil)}\n\n"
8755
+ marker = local_gemfile_nomono_bootstrap_marker(output)
8756
+ activation_assignments = prism_descendants(output).select do |node|
8757
+ node.is_a?(::Prism::LocalVariableWriteNode) && node.name == :nomono_activation_requirements
8758
+ end
8759
+ if marker && activation_assignments.any?
8760
+ first_line = activation_assignments.map { |node| node.location.start_line }.min
8761
+ first_line = preceding_comment_block_start_line(output.lines, first_line)
8762
+ output = replace_source_range_lines(output, first_line, marker.fetch(:start_line) - 1, bootstrap) if first_line < marker.fetch(:start_line)
8763
+ output = remove_local_gemfile_nomono_loader_blocks_after_marker(output)
8764
+ output = remove_redundant_nomono_bundler_requires_after_marker(output)
8765
+ return ensure_trailing_newline(output.rstrip)
8766
+ end
8767
+
8979
8768
  require_records = ruby_call_records(output, :require).filter_map do |call|
8980
8769
  next unless ruby_string_argument(call) == "nomono/bundler"
8981
8770
 
@@ -8999,6 +8788,82 @@ module Kettle
8999
8788
  end
9000
8789
  end
9001
8790
 
8791
+ def local_gemfile_nomono_bootstrap_marker(content)
8792
+ result = prism_parse_success(content)
8793
+ body = result&.value&.statements&.body || []
8794
+ local_gems_assignment = body.find do |node|
8795
+ node.is_a?(::Prism::LocalVariableWriteNode) && %i[local_gems structuredmerge_local_gems].include?(node.name)
8796
+ end
8797
+ [
8798
+ local_gems_assignment_record(content),
8799
+ local_gems_assignment && {start_line: local_gems_assignment.location.start_line},
8800
+ word_array_assignment_record(content, :structuredmerge_local_gems)
8801
+ ].compact.min_by { |record| record.fetch(:start_line) }
8802
+ end
8803
+
8804
+ def remove_local_gemfile_nomono_loader_blocks_after_marker(content)
8805
+ marker = local_gemfile_nomono_bootstrap_marker(content)
8806
+ return content unless marker
8807
+
8808
+ result = prism_parse_success(content)
8809
+ body = result&.value&.statements&.body || []
8810
+ ranges = body.each_with_index.filter_map do |node, index|
8811
+ next unless node.is_a?(::Prism::LocalVariableWriteNode) && %i[nomono_activation_requirements nomono_local_loader].include?(node.name)
8812
+ next unless node.location.start_line > marker.fetch(:start_line)
8813
+
8814
+ loader = if node.name == :nomono_local_loader
8815
+ node
8816
+ else
8817
+ loader_index = (index + 1...body.length).find do |candidate_index|
8818
+ candidate = body[candidate_index]
8819
+ candidate.is_a?(::Prism::LocalVariableWriteNode) && candidate.name == :nomono_local_loader
8820
+ end
8821
+ loader_index && body[loader_index]
8822
+ end
8823
+ next unless loader
8824
+ next if node.name == :nomono_local_loader && body[0...index].any? do |candidate|
8825
+ candidate.is_a?(::Prism::LocalVariableWriteNode) && candidate.name == :nomono_activation_requirements &&
8826
+ candidate.location.start_line > marker.fetch(:start_line)
8827
+ end
8828
+
8829
+ following = body[(body.index(loader) + 1)..].to_a.find do |candidate|
8830
+ candidate.is_a?(::Prism::IfNode) && prism_descendants(candidate.slice).any? do |descendant|
8831
+ descendant.is_a?(::Prism::CallNode) && descendant.name == :require
8832
+ end
8833
+ end
8834
+ {
8835
+ start_line: (node.name == :nomono_activation_requirements) ?
8836
+ preceding_comment_block_start_line(content.to_s.lines, node.location.start_line) : node.location.start_line,
8837
+ end_line: ruby_node_source_end_line(following || loader)
8838
+ }
8839
+ end
8840
+ ranges.sort_by { |range| -range.fetch(:start_line) }.reduce(content.to_s) do |output, range|
8841
+ replace_source_range_lines(output, range.fetch(:start_line), range.fetch(:end_line), "")
8842
+ end
8843
+ end
8844
+
8845
+ def remove_redundant_nomono_bundler_requires_after_marker(content)
8846
+ marker = local_gemfile_nomono_bootstrap_marker(content)
8847
+ return content unless marker
8848
+
8849
+ records = ruby_call_records(content, :require).filter_map do |call|
8850
+ next unless ruby_string_argument(call) == "nomono/bundler"
8851
+ next unless call.location.start_line > marker.fetch(:start_line)
8852
+
8853
+ {start_line: call.location.start_line, end_line: ruby_node_source_end_line(call)}
8854
+ end
8855
+ records.sort_by { |record| -record.fetch(:start_line) }.reduce(content.to_s) do |output, record|
8856
+ replace_source_range_lines(output, record.fetch(:start_line), record.fetch(:end_line), "")
8857
+ end
8858
+ end
8859
+
8860
+ def prism_descendants(content)
8861
+ result = prism_parse_success(content)
8862
+ return [] unless result
8863
+
8864
+ result.value.breadth_first_search_all { true }
8865
+ end
8866
+
9002
8867
  def remove_obsolete_local_gemfile_nomono_activation(content)
9003
8868
  records = obsolete_local_gemfile_nomono_activation_records(content)
9004
8869
  return content if records.empty?
@@ -9414,7 +9279,7 @@ module Kettle
9414
9279
 
9415
9280
  def merge_gemfile_template_policy(content, facts:, template_content: nil, preserve_self_word_entries: false)
9416
9281
  package_name = facts.dig(:package, :name).to_s if facts
9417
- removable_gems = ["appraisal", *PROHIBITED_GEMFILE_DEPENDENCIES]
9282
+ removable_gems = [*PROHIBITED_GEMFILE_DEPENDENCIES]
9418
9283
  removable_gems << package_name unless package_name.to_s.empty?
9419
9284
  removable_gems.concat(package_runtime_dependency_names(facts))
9420
9285
  removable_gems << "version_gem" unless version_gem_enabled?(facts)
@@ -9431,7 +9296,16 @@ module Kettle
9431
9296
 
9432
9297
  def package_runtime_dependency_names(facts)
9433
9298
  dependencies = facts.to_h.dig(:package, :runtime_dependencies)
9434
- Array(dependencies).map(&:to_s).reject(&:empty?).uniq
9299
+ Array(dependencies).filter_map do |dependency|
9300
+ name = if dependency.respond_to?(:name)
9301
+ dependency.name
9302
+ elsif dependency.is_a?(Hash)
9303
+ dependency[:name] || dependency["name"]
9304
+ else
9305
+ dependency
9306
+ end
9307
+ name.to_s unless name.to_s.empty?
9308
+ end.uniq
9435
9309
  end
9436
9310
 
9437
9311
  def inject_main_gemfile_recording_eval(content, facts)
@@ -9652,6 +9526,22 @@ module Kettle
9652
9526
  ensure_trailing_newline(lines.each_with_index.reject { |_line, index| remove_indexes.include?(index) }.map(&:first).join.gsub(/\n{3,}/, "\n\n"))
9653
9527
  end
9654
9528
 
9529
+ # Bundler already provides these git sources; see BUNDLER_BUILTIN_GIT_SOURCES.
9530
+ def remove_gemfile_builtin_git_source_overrides(content)
9531
+ remove_indexes = Set.new
9532
+ ruby_call_records(content, :git_source).each do |call|
9533
+ argument = call.arguments&.arguments&.first
9534
+ next unless argument.is_a?(::Prism::SymbolNode)
9535
+ next unless BUNDLER_BUILTIN_GIT_SOURCES.include?(argument.unescaped.to_s)
9536
+
9537
+ (call.location.start_line..ruby_node_source_end_line(call)).each { |line_number| remove_indexes << (line_number - 1) }
9538
+ end
9539
+ return content if remove_indexes.empty?
9540
+
9541
+ lines = content.to_s.lines.each_with_index.reject { |_line, index| remove_indexes.include?(index) }.map(&:first)
9542
+ ensure_trailing_newline(lines.join.sub(/\A\n+/, "").gsub(/\n{3,}/, "\n\n"))
9543
+ end
9544
+
9655
9545
  def gemfile_gem_call_records(content)
9656
9546
  ruby_call_records(content, :gem).filter_map do |call|
9657
9547
  name = ruby_string_argument(call)
@@ -9669,21 +9559,54 @@ module Kettle
9669
9559
  # A dependency owned directly by the project must not also be introduced by
9670
9560
  # a generated modular Gemfile. On first bootstrap, write a reviewable
9671
9561
  # resolution list; later runs require an explicit removal operation.
9562
+ #
9563
+ # Also surfaces case 3 of the dev-dependency conflict policy (engine
9564
+ # incompatibility, e.g. sqlite3 on jruby): see #engine_dependency_conflicts.
9565
+ # Both conflict sources share the same dependency_conflicts.resolve list,
9566
+ # decision-matching, and review gate.
9672
9567
  def validate_modular_dependency_conflicts!(project_root, recipe_reports)
9673
- conflicts = modular_dependency_conflicts(project_root, recipe_reports)
9568
+ validate_stale_modular_dependency_conflicts!(project_root)
9569
+ config = kettle_jem_config(project_root)
9570
+ conflicts = modular_dependency_conflicts(project_root, recipe_reports) +
9571
+ engine_dependency_conflicts(project_root, recipe_reports, config)
9674
9572
  return if conflicts.empty?
9675
9573
 
9676
- config = kettle_jem_config(project_root)
9677
9574
  decisions = modular_dependency_conflict_decisions(config)
9678
9575
  unresolved = conflicts.reject { |conflict| modular_dependency_conflict_decided?(decisions, conflict) }
9679
9576
  if unresolved.empty?
9680
9577
  return
9681
9578
  end
9682
9579
 
9580
+ # A conflict is "newly surfaced" only if no entry exists yet in
9581
+ # dependency_conflicts.resolve for its exact gem/direct/modular triple
9582
+ # (regardless of whether that entry's action/reason are filled in or
9583
+ # valid). Newly surfaced conflicts get a placeholder entry written for
9584
+ # review and do not block this run. A conflict that already has an
9585
+ # entry but is still undecided (e.g. the placeholder was never
9586
+ # completed, or reviewed was never flipped to true) has already had
9587
+ # its chance to be reviewed, so it blocks the run instead of silently
9588
+ # regenerating the same placeholder forever.
9589
+ surfaced_entries = modular_dependency_conflict_entries(config)
9590
+ newly_surfaced, previously_surfaced = unresolved.partition do |conflict|
9591
+ !modular_dependency_conflict_previously_surfaced?(surfaced_entries, conflict)
9592
+ end
9593
+
9594
+ # Among newly surfaced conflicts, gems with a canonical, pre-decided
9595
+ # resolution (KNOWN_GEM_CONFLICT_RESOLUTIONS) don't need a human in
9596
+ # the loop: write the real decided entry straight away instead of a
9597
+ # placeholder "review" entry. Everything else still goes through the
9598
+ # placeholder/review flow.
9599
+ canonical_surfaced, review_needed_surfaced = newly_surfaced.partition do |conflict|
9600
+ auto_resolvable_known_gem_conflict?(conflict)
9601
+ end
9602
+
9683
9603
  bootstrap_report = recipe_reports.find { |report| report.fetch(:relative_path, "").to_s == KETTLE_CONFIG_PATH }
9684
- if bootstrap_report && !modular_dependency_conflicts_reviewed?(config)
9685
- bootstrap_report[:final_content] = add_dependency_conflict_review_list(bootstrap_report.fetch(:final_content, ""), conflicts)
9686
- return
9604
+ if bootstrap_report && newly_surfaced.any?
9605
+ content = bootstrap_report.fetch(:final_content, "")
9606
+ content = add_dependency_conflict_resolved_entries(content, canonical_surfaced) if canonical_surfaced.any?
9607
+ content = add_dependency_conflict_review_list(content, review_needed_surfaced) if review_needed_surfaced.any?
9608
+ bootstrap_report[:final_content] = content
9609
+ return if previously_surfaced.empty?
9687
9610
  end
9688
9611
 
9689
9612
  details = unresolved.sort_by { |conflict| conflict.values_at(:name, :direct, :modular) }.map do |conflict|
@@ -9692,13 +9615,60 @@ module Kettle
9692
9615
  raise Error,
9693
9616
  "direct dependencies also declared by templated modular Gemfiles: #{details}. " \
9694
9617
  "Review dependency_conflicts.resolve in #{KETTLE_CONFIG_PATH}; each entry needs gem, direct, modular, action, and reason. " \
9695
- "Supported actions: remove_modular_gem, remove_x_std_lib_eval, keep_both. " \
9618
+ "Supported actions: remove_modular_gem, remove_x_std_lib_eval, remove_direct_gem, keep_both. " \
9696
9619
  "keep_both is valid only when the direct and modular requirements overlap."
9697
9620
  end
9698
9621
 
9622
+ # A remove_modular_gem / remove_x_std_lib_eval / remove_direct_gem entry
9623
+ # that cites a gemspec as its direct side claims the gemspec declares the
9624
+ # gem ("the gemspec owns the dependency"). When the gemspec has since
9625
+ # stopped declaring it, the entry is stale: every templating run strips a
9626
+ # modular declaration the template would otherwise maintain, and downstream
9627
+ # bundles quietly lose the gem (observed: appraisal2-rubocop failing with
9628
+ # "Unable to find gem rubocop-lts" because .rubocop.yml inherit_gem needed
9629
+ # a gem the conflict entry had been deleting). These are warnings, not
9630
+ # errors — some stale entries are intentional deactivations (e.g. a member
9631
+ # deliberately disabling the yard-junk task) — but each one deserves a
9632
+ # human look, because the recorded reason no longer describes reality.
9633
+ def validate_stale_modular_dependency_conflicts!(project_root)
9634
+ config = kettle_jem_config(project_root)
9635
+ decisions = modular_dependency_conflict_decisions(config)
9636
+ return if decisions.empty?
9637
+
9638
+ gemspec_paths = Dir.glob(File.join(project_root.to_s, "*.gemspec"))
9639
+ gemspec_content = gemspec_paths.filter_map { |path| File.read(path) if File.readable?(path) }.join("\n")
9640
+ return if gemspec_paths.empty?
9641
+
9642
+ stale = decisions.select do |decision|
9643
+ next false unless decision.fetch("direct", "").end_with?(".gemspec")
9644
+ next false unless %w[remove_modular_gem remove_x_std_lib_eval].include?(decision.fetch("action"))
9645
+
9646
+ gem_name = decision.fetch("gem")
9647
+ !gemspec_dependency_records(gemspec_content).any? { |record| record.fetch(:name) == gem_name }
9648
+ end
9649
+ return if stale.empty?
9650
+
9651
+ details = stale.map do |decision|
9652
+ "#{decision.fetch("gem")} (#{decision.fetch("direct")} vs #{decision.fetch("modular")})"
9653
+ end.join(", ")
9654
+ warn(
9655
+ "kettle-jem: stale dependency_conflicts entries — the gemspec no longer declares: #{details}. " \
9656
+ "If the removal is intentional, update the reason in #{KETTLE_CONFIG_PATH} to say so; " \
9657
+ "otherwise remove the entry so the template maintains the modular declaration."
9658
+ )
9659
+ end
9660
+
9661
+ # Paths of a project's own directly-authored dependency manifests: the
9662
+ # root Gemfile plus any gemspec, whether already on disk or only present
9663
+ # as a pending recipe report (not yet written) for this templating run.
9664
+ def direct_dependency_paths(project_root, recipe_reports)
9665
+ report_paths = recipe_reports.map { |report| report.fetch(:relative_path, "").to_s }
9666
+ ["Gemfile", *Dir.glob(File.join(project_root.to_s, "*.gemspec")).map { |path| File.basename(path) }, *report_paths.select { |path| path.end_with?(".gemspec") }]
9667
+ end
9668
+
9699
9669
  def modular_dependency_conflicts(project_root, recipe_reports)
9700
9670
  report_paths = recipe_reports.map { |report| report.fetch(:relative_path, "").to_s }
9701
- paths = ["Gemfile", *Dir.glob(File.join(project_root.to_s, "*.gemspec")).map { |path| File.basename(path) }, *report_paths.select { |path| path.end_with?(".gemspec") }]
9671
+ paths = direct_dependency_paths(project_root, recipe_reports)
9702
9672
  direct = dependency_file_contents(project_root, recipe_reports, paths)
9703
9673
  modular_paths = [
9704
9674
  *Dir.glob(File.join(project_root.to_s, "gemfiles/modular/**/*.gemfile")).map do |path|
@@ -9716,6 +9686,12 @@ module Kettle
9716
9686
  relative = path.delete_prefix("gemfiles/modular/")
9717
9687
  !relative.include?("/") || relative.start_with?("x_std_libs/")
9718
9688
  end
9689
+ # shunted.gemfile is derived from the gemspec's own dependencies
9690
+ # (#shunted_gemfile_block): it's expected to redeclare a gem the
9691
+ # gemspec already declares, so it isn't a real direct-vs-modular
9692
+ # authoring conflict. Every other consumer of the packaged modular
9693
+ # gemfile list excludes it the same way.
9694
+ modular_paths = modular_paths.reject { |path| path == "gemfiles/modular/shunted.gemfile" }
9719
9695
  modular = dependency_file_contents(project_root, recipe_reports, modular_paths)
9720
9696
  direct.flat_map do |direct_path, content|
9721
9697
  direct_records = dependency_records_for(direct_path, content)
@@ -9736,6 +9712,41 @@ module Kettle
9736
9712
  end
9737
9713
  end
9738
9714
 
9715
+ # Case 3 of the dev-dependency conflict policy: a direct dependency whose
9716
+ # KNOWN_GEM_CONFLICT_RESOLUTIONS entry is :engine_incompatible and
9717
+ # doesn't support one or more of the destination's declared engines
9718
+ # (config["engines"], falling back to DEFAULT_ENGINES — see
9719
+ # #enabled_ruby_engines). Unlike #modular_dependency_conflicts, this
9720
+ # needs no modular-side declaration to match against: these are gems
9721
+ # below the template's inclusion threshold with no template-managed
9722
+ # modular home (sqlite3 is the pilot; see KNOWN_GEM_CONFLICT_RESOLUTIONS), so the
9723
+ # incompatibility itself — not a direct-vs-modular duplicate — is the
9724
+ # conflict. The :modular field is a fixed, human-readable placeholder
9725
+ # (there is no modular file to name) rather than the specific
9726
+ # unsupported-engine list, so a decision stays matched across runs even
9727
+ # if the destination's declared engines change without actually
9728
+ # resolving the incompatibility.
9729
+ def engine_dependency_conflicts(project_root, recipe_reports, config)
9730
+ declared_engines = enabled_ruby_engines(config)
9731
+ paths = direct_dependency_paths(project_root, recipe_reports)
9732
+ direct = dependency_file_contents(project_root, recipe_reports, paths)
9733
+ direct.flat_map do |direct_path, content|
9734
+ dependency_records_for(direct_path, content).filter_map do |record|
9735
+ resolution = known_gem_conflict_resolution(record.fetch(:name))
9736
+ next unless resolution && resolution.fetch(:category) == :engine_incompatible
9737
+ next if (declared_engines - resolution.fetch(:supported_engines, [])).empty?
9738
+
9739
+ {
9740
+ name: record.fetch(:name),
9741
+ direct: direct_path,
9742
+ modular: "(engine-incompatible; no modular home)",
9743
+ direct_requirements: record.fetch(:requirements, []),
9744
+ modular_requirements: []
9745
+ }
9746
+ end
9747
+ end
9748
+ end
9749
+
9739
9750
  def dependency_records_for(path, content)
9740
9751
  if path == "Gemfile" || path.end_with?(".gemfile")
9741
9752
  gemfile_gem_call_records(content)
@@ -9771,13 +9782,34 @@ module Kettle
9771
9782
  config.dig("dependency_conflicts", "reviewed") == true
9772
9783
  end
9773
9784
 
9785
+ # Every entry currently in dependency_conflicts.resolve, keyed loosely
9786
+ # (unlike #modular_dependency_conflict_decisions, incomplete entries such
9787
+ # as an unfinished "action: review" placeholder are included) so callers
9788
+ # can tell "this conflict was already surfaced for review" apart from
9789
+ # "this conflict has never been seen before".
9790
+ def modular_dependency_conflict_entries(config)
9791
+ Array(config.dig("dependency_conflicts", "resolve")).filter_map do |entry|
9792
+ next unless entry.is_a?(Hash)
9793
+
9794
+ entry.each_with_object({}) { |(key, value), result| result[key.to_s] = value.to_s }
9795
+ end
9796
+ end
9797
+
9798
+ def modular_dependency_conflict_previously_surfaced?(entries, conflict)
9799
+ entries.any? do |entry|
9800
+ entry["gem"] == conflict.fetch(:name) &&
9801
+ entry["direct"] == conflict.fetch(:direct) &&
9802
+ entry["modular"] == conflict.fetch(:modular)
9803
+ end
9804
+ end
9805
+
9774
9806
  def modular_dependency_conflict_decided?(decisions, conflict)
9775
9807
  decisions.any? do |decision|
9776
9808
  decision.fetch("gem") == conflict.fetch(:name) &&
9777
9809
  decision.fetch("direct") == conflict.fetch(:direct) &&
9778
9810
  decision.fetch("modular") == conflict.fetch(:modular) &&
9779
9811
  case decision.fetch("action")
9780
- when "remove_modular_gem", "remove_x_std_lib_eval"
9812
+ when "remove_modular_gem", "remove_x_std_lib_eval", "remove_direct_gem"
9781
9813
  true
9782
9814
  when "keep_both"
9783
9815
  compatible_dependency_requirements?(conflict.fetch(:direct_requirements), conflict.fetch(:modular_requirements))
@@ -9807,38 +9839,118 @@ module Kettle
9807
9839
  false
9808
9840
  end
9809
9841
 
9842
+ # A gem qualifies for automatic (no-human-review) resolution when it has
9843
+ # a KNOWN_GEM_CONFLICT_RESOLUTIONS entry in
9844
+ # AUTO_RESOLVABLE_KNOWN_GEM_CONFLICT_CATEGORIES and not explicitly
9845
+ # flagged force_review — see #auto_resolvable_known_gem_conflict? below.
9846
+ def known_gem_conflict_resolution(name)
9847
+ KNOWN_GEM_CONFLICT_RESOLUTIONS[name.to_s]
9848
+ end
9849
+
9850
+ AUTO_RESOLVABLE_KNOWN_GEM_CONFLICT_CATEGORIES = %i[template_managed engine_incompatible].freeze
9851
+
9852
+ def auto_resolvable_known_gem_conflict?(conflict)
9853
+ resolution = known_gem_conflict_resolution(conflict.fetch(:name))
9854
+ return false unless resolution
9855
+
9856
+ AUTO_RESOLVABLE_KNOWN_GEM_CONFLICT_CATEGORIES.include?(resolution.fetch(:category)) &&
9857
+ !resolution.fetch(:force_review, false)
9858
+ end
9859
+
9860
+ def dependency_conflict_entry_lines(conflict, action:, reason:)
9861
+ [
9862
+ " - gem: #{conflict.fetch(:name)}\n",
9863
+ " direct: #{conflict.fetch(:direct)}\n",
9864
+ " modular: #{conflict.fetch(:modular)}\n",
9865
+ " action: #{action}\n",
9866
+ " reason: #{reason.to_s.empty? ? "\"\"" : reason.to_s.inspect}\n"
9867
+ ]
9868
+ end
9869
+
9870
+ # Writes a placeholder "action: review" entry for each newly surfaced
9871
+ # conflict. When dependency_conflicts: already exists, this only adds
9872
+ # entries for these specific conflicts and resets reviewed: to false
9873
+ # (a new, unreviewed conflict invalidates a prior reviewed: true) —
9874
+ # existing entries, including ones a human already wrote, are left
9875
+ # exactly as they are.
9810
9876
  def add_dependency_conflict_review_list(content, conflicts)
9877
+ return content if conflicts.empty?
9878
+
9879
+ new_entry_lines = conflicts.sort_by { |conflict| conflict.values_at(:name, :direct, :modular) }.flat_map do |conflict|
9880
+ dependency_conflict_entry_lines(conflict, action: "review", reason: "")
9881
+ end
9882
+
9883
+ insert_dependency_conflict_resolve_entries(content, new_entry_lines, reset_reviewed: true)
9884
+ end
9885
+
9886
+ # Writes a fully decided entry (real action + reason, straight from
9887
+ # KNOWN_GEM_CONFLICT_RESOLUTIONS) for each newly surfaced conflict whose
9888
+ # gem is canonical. Unlike add_dependency_conflict_review_list, these
9889
+ # need no human decision, so reviewed: is left exactly as it already is
9890
+ # instead of being reset to false.
9891
+ def add_dependency_conflict_resolved_entries(content, conflicts)
9892
+ return content if conflicts.empty?
9893
+
9894
+ new_entry_lines = conflicts.sort_by { |conflict| conflict.values_at(:name, :direct, :modular) }.flat_map do |conflict|
9895
+ resolution = known_gem_conflict_resolution(conflict.fetch(:name))
9896
+ dependency_conflict_entry_lines(conflict, action: resolution.fetch(:action), reason: resolution.fetch(:reason))
9897
+ end
9898
+
9899
+ insert_dependency_conflict_resolve_entries(content, new_entry_lines, reset_reviewed: false)
9900
+ end
9901
+
9902
+ # Shared splicing logic behind both writers above: finds (or creates)
9903
+ # the dependency_conflicts: block and appends new_entry_lines under
9904
+ # resolve:, optionally resetting reviewed: to false along the way.
9905
+ def insert_dependency_conflict_resolve_entries(content, new_entry_lines, reset_reviewed:)
9811
9906
  lines = content.to_s.lines
9812
9907
  index = lines.index { |line| line.match?(/\Adependency_conflicts:\s*\z/) }
9813
9908
  unless index
9814
9909
  lines << "\n" unless lines.empty? || lines.last.strip.empty?
9815
- lines.concat(["dependency_conflicts:\n", " # Review each entry and choose a supported action.\n", " resolve:\n"])
9816
- index = lines.length - 3
9910
+ lines.concat([
9911
+ "dependency_conflicts:\n",
9912
+ " # Review each entry and choose a supported action.\n",
9913
+ " # keep_both is for a broad direct requirement plus a compatible modular narrowing.\n",
9914
+ " reviewed: false\n",
9915
+ " resolve:\n",
9916
+ *new_entry_lines
9917
+ ])
9918
+ return ensure_trailing_newline(lines.join)
9817
9919
  end
9818
9920
 
9819
9921
  block_end = (index + 1...lines.length).find { |line_index| lines[line_index].match?(/\A\S/) } || lines.length
9820
- replacement = [
9821
- "dependency_conflicts:\n",
9822
- " # Review each entry and choose a supported action.\n",
9823
- " # keep_both is for a broad direct requirement plus a compatible modular narrowing.\n",
9824
- " reviewed: false\n",
9825
- " resolve:\n"
9826
- ]
9827
- conflicts.sort_by { |conflict| conflict.values_at(:name, :direct, :modular) }.each do |conflict|
9828
- replacement.concat([
9829
- " - gem: #{conflict.fetch(:name)}\n",
9830
- " direct: #{conflict.fetch(:direct)}\n",
9831
- " modular: #{conflict.fetch(:modular)}\n",
9832
- " action: review\n",
9833
- " reason: \"\"\n"
9834
- ])
9922
+ block_lines = lines[index...block_end]
9923
+
9924
+ if reset_reviewed
9925
+ reviewed_index = block_lines.index { |line| line.match?(/\A\s*reviewed:\s*\S/) }
9926
+ if reviewed_index
9927
+ block_lines[reviewed_index] = block_lines[reviewed_index].sub(/reviewed:\s*\S+/, "reviewed: false")
9928
+ else
9929
+ insert_at = 1
9930
+ insert_at += 1 while block_lines[insert_at]&.match?(/\A\s*#/)
9931
+ block_lines.insert(insert_at, " reviewed: false\n")
9932
+ end
9933
+ end
9934
+
9935
+ resolve_index = block_lines.index { |line| line.match?(/\A\s*resolve:\s*(\[\s*\])?\s*\z/) }
9936
+ if resolve_index
9937
+ if block_lines[resolve_index].match?(/\[\s*\]\s*\z/)
9938
+ indent = block_lines[resolve_index][/\A\s*/]
9939
+ block_lines[resolve_index] = "#{indent}resolve:\n"
9940
+ end
9941
+ block_lines.insert(resolve_index + 1, *new_entry_lines)
9942
+ else
9943
+ block_lines << " resolve:\n"
9944
+ block_lines.concat(new_entry_lines)
9835
9945
  end
9836
- ensure_trailing_newline([*lines[0...index], *replacement, *lines[block_end..].to_a].join)
9946
+
9947
+ ensure_trailing_newline([*lines[0...index], *block_lines, *lines[block_end..].to_a].join)
9837
9948
  end
9838
9949
 
9839
9950
  def apply_modular_dependency_conflict_resolutions(project_root, decisions)
9840
9951
  decisions.each_with_object([]) do |decision, changed_files|
9841
- path = File.join(project_root.to_s, decision.fetch("modular"))
9952
+ relative_path = (decision.fetch("action") == "remove_direct_gem") ? decision.fetch("direct") : decision.fetch("modular")
9953
+ path = File.join(project_root.to_s, relative_path)
9842
9954
  next unless File.file?(path)
9843
9955
 
9844
9956
  before = File.read(path)
@@ -9847,13 +9959,33 @@ module Kettle
9847
9959
  remove_gemfile_dependency_blocks(before, [decision.fetch("gem")])
9848
9960
  when "remove_x_std_lib_eval"
9849
9961
  remove_x_std_lib_eval_for_gem(before, decision.fetch("gem"))
9962
+ when "remove_direct_gem"
9963
+ remove_dependency_conflict_direct_declaration(before, relative_path, decision.fetch("gem"))
9850
9964
  when "keep_both"
9851
9965
  before
9852
9966
  end
9853
9967
  next if after == before
9854
9968
 
9855
9969
  File.write(path, after)
9856
- changed_files << decision.fetch("modular")
9970
+ changed_files << relative_path
9971
+ end
9972
+ end
9973
+
9974
+ # The "direct" side of a dependency_conflicts entry is either a Gemfile
9975
+ # (or a modular .gemfile) or a .gemspec, mirroring the same dispatch
9976
+ # #dependency_records_for uses to detect the conflict in the first
9977
+ # place. The gemspec branch has to discover its own receiver (the
9978
+ # Gem::Specification.new block's parameter name) since, unlike the
9979
+ # gemspec-merge pipeline, there's no already-known template_receiver
9980
+ # here — this runs standalone, against whatever's on disk.
9981
+ def remove_dependency_conflict_direct_declaration(content, relative_path, gem_name)
9982
+ if relative_path == "Gemfile" || relative_path.end_with?(".gemfile")
9983
+ remove_gemfile_dependency_blocks(content, [gem_name])
9984
+ else
9985
+ receiver = gemspec_block_param(content)
9986
+ return content unless receiver
9987
+
9988
+ remove_gemspec_dependency_lines(content, receiver: receiver, names: [gem_name])
9857
9989
  end
9858
9990
  end
9859
9991
 
@@ -9958,12 +10090,23 @@ module Kettle
9958
10090
  TEMPLATE_MANAGED_DEPENDENCIES.find { |dependency| dependency.fetch(:name) == name.to_s }
9959
10091
  end
9960
10092
 
9961
- def template_managed_dependency_requirements(name, version_module: Version)
9962
- return kettle_jem_dependency_requirements(version_module: version_module) if name.to_s == PACKAGE_NAME
10093
+ def template_managed_dependency_requirements(name, env: ENV, version_module: Version)
10094
+ return kettle_jem_template_dependency_requirements(env: env, version_module: version_module) if name.to_s == PACKAGE_NAME
9963
10095
 
9964
10096
  template_managed_dependency(name)&.fetch(:requirements)
9965
10097
  end
9966
10098
 
10099
+ def kettle_jem_template_dependency_requirements(env: ENV, version_module: Version)
10100
+ return kettle_jem_dependency_requirements(version_module: version_module) unless local_structuredmerge_path_mode?(env)
10101
+
10102
+ template_managed_dependency(PACKAGE_NAME).fetch(:requirements)
10103
+ end
10104
+
10105
+ def local_structuredmerge_path_mode?(env)
10106
+ value = (env || {}).fetch("STRUCTUREDMERGE_DEV", "false").to_s.strip
10107
+ value != "" && !DecisionPolicy.falsey?(value)
10108
+ end
10109
+
9967
10110
  def template_managed_dependency_names(bootstrap: nil, ruby_version: RUBY_VERSION)
9968
10111
  TEMPLATE_MANAGED_DEPENDENCIES.filter_map do |dependency|
9969
10112
  next if !bootstrap.nil? && dependency.fetch(:bootstrap) != bootstrap
@@ -9984,12 +10127,12 @@ module Kettle
9984
10127
  Gem::Requirement.new(requirement).satisfied_by?(Gem::Version.new(ruby_version))
9985
10128
  end
9986
10129
 
9987
- def reconcile_template_managed_dependencies(source)
10130
+ def reconcile_template_managed_dependencies(source, env: ENV)
9988
10131
  replacements = ruby_call_records(source, nil).filter_map do |call|
9989
10132
  next unless template_managed_dependency_call?(call)
9990
10133
 
9991
10134
  name = ruby_string_argument(call)
9992
- requirements = template_managed_dependency_requirements(name)
10135
+ requirements = template_managed_dependency_requirements(name, env: env)
9993
10136
  next unless requirements
9994
10137
 
9995
10138
  requirement_nodes = Array(call.arguments&.arguments).drop(1).reject do |argument|
@@ -11128,14 +11271,59 @@ module Kettle
11128
11271
 
11129
11272
  def remove_gemspec_dependency_lines(content, receiver:, names:, runtime_only: false, development_only: false)
11130
11273
  wanted = names.map(&:to_s).to_set
11131
- records_by_line = gemspec_dependency_records(content, receiver: receiver)
11132
- .select { |record| wanted.include?(record.fetch(:name)) }
11133
- .select { |record| !runtime_only || record.fetch(:kind) != "add_development_dependency" }
11134
- .select { |record| !development_only || record.fetch(:kind) == "add_development_dependency" }
11135
- .to_h { |record| [record.fetch(:start_line), record.merge(replacement: "")] }
11136
- return content if records_by_line.empty?
11274
+ return content if wanted.empty?
11137
11275
 
11138
- ensure_trailing_newline(replace_record_ranges(content, records_by_line).gsub(/\n{3,}/, "\n\n"))
11276
+ allowed_kinds = gemspec_dependency_call_kinds(runtime_only: runtime_only, development_only: development_only)
11277
+ targets = wanted.map do |name|
11278
+ ast_crispr_gemspec_dependency_delete_target(receiver: receiver, name: name, allowed_kinds: allowed_kinds)
11279
+ end
11280
+
11281
+ result = Ast::Crispr::DeleteBatch.call(content: content, targets: targets, source_label: "gemspec")
11282
+ return content unless result.changed
11283
+
11284
+ ensure_trailing_newline(result.updated_content.gsub(/\n{3,}/, "\n\n"))
11285
+ end
11286
+
11287
+ def gemspec_dependency_call_kinds(runtime_only: false, development_only: false)
11288
+ kinds = %w[add_dependency add_runtime_dependency add_development_dependency]
11289
+ kinds = kinds.reject { |kind| kind == "add_development_dependency" } if runtime_only
11290
+ kinds = kinds.select { |kind| kind == "add_development_dependency" } if development_only
11291
+ kinds.map(&:to_sym)
11292
+ end
11293
+
11294
+ # Removes a single named gemspec dependency call, along with its owned
11295
+ # leading comment (if any) — matching by call name and the same
11296
+ # receiver, kind, and first-argument matching #gemspec_dependency_records
11297
+ # uses, but locating the call anywhere in the tree via the :all_statements
11298
+ # owner scope (not just the top level), and deleting it with
11299
+ # Ast::Crispr::DeleteBatch instead of raw line-range replacement so its
11300
+ # owned comments come with it rather than being left dangling.
11301
+ def ast_crispr_gemspec_dependency_delete_target(receiver:, name:, allowed_kinds:)
11302
+ Ast::Crispr::Ruby::Prism::Selectors.owner_filter(
11303
+ id: "remove_gemspec_dependency_#{receiver}_#{name}",
11304
+ owner_scope: :all_statements,
11305
+ limit: {none_or_one: true}
11306
+ ) do |context, owner|
11307
+ next false unless owner.respond_to?(:receiver) && owner.respond_to?(:name)
11308
+ next false unless owner.receiver&.slice == receiver.to_s
11309
+ next false unless allowed_kinds.include?(owner.name)
11310
+
11311
+ argument = owner.arguments&.arguments&.first
11312
+ next false unless ruby_static_string_value(argument) == name
11313
+
11314
+ leading = context.comment_regions_for(owner, region: :leading, owner_scope: :all_statements)
11315
+ start_line = leading.any? ? leading.map { |comment| comment.location.start_line }.min : owner.location.start_line
11316
+ Ast::Crispr::Match.new(
11317
+ node: owner,
11318
+ start_line: start_line,
11319
+ end_line: owner.location.end_line,
11320
+ metadata: {
11321
+ start_boundary: (leading.any? ? :comment_region_start : :owner_start),
11322
+ end_boundary: :owner_end,
11323
+ payload_kind: :structural_owner_body
11324
+ }
11325
+ )
11326
+ end
11139
11327
  end
11140
11328
 
11141
11329
  def remove_ruby_comment_lines_containing(content, text)
@@ -11667,8 +11855,8 @@ module Kettle
11667
11855
 
11668
11856
  case expression.receiver&.slice
11669
11857
  when "enumerate_package_files"
11670
- argument = Array(expression.arguments&.arguments).first
11671
- %w[lib exe certs sig].include?(ruby_static_string_value(argument))
11858
+ arguments = Array(expression.arguments&.arguments)
11859
+ arguments.length == 1 && !ruby_static_string_value(arguments.first).nil?
11672
11860
  when "enumerate_package_glob"
11673
11861
  generated_gemspec_package_glob_call?(expression)
11674
11862
  else
@@ -12495,11 +12683,13 @@ module Kettle
12495
12683
  content = resolve_template_tokens(content, tokens, scan_unresolved: false)
12496
12684
  bootstrap_licenses = Array(recipe[:bootstrap_licenses]).map(&:to_s).reject(&:empty?)
12497
12685
  content = replace_kettle_config_bootstrap_licenses(content, bootstrap_licenses) unless bootstrap_licenses.empty?
12686
+ bootstrap_platforms = Array(recipe[:bootstrap_platforms]).map(&:to_s).reject(&:empty?)
12687
+ content = replace_kettle_config_bootstrap_platforms(content, bootstrap_platforms) unless bootstrap_platforms.empty?
12498
12688
  content = replace_kettle_config_bootstrap_project_emoji(content, recipe[:bootstrap_project_emoji]) unless recipe[:bootstrap_project_emoji].to_s.empty?
12499
12689
  content = replace_kettle_config_bootstrap_rubyforum(content, recipe[:bootstrap_rubyforum]) if recipe[:bootstrap_rubyforum].is_a?(Hash)
12500
12690
  content = apply_kettle_config_bootstrap_profile(content, recipe[:bootstrap_template_profile], recipe[:bootstrap_gemspec_path])
12501
12691
  content = add_shim_bootstrap_config(content, recipe[:bootstrap_shim]) if recipe[:bootstrap_shim].is_a?(Hash)
12502
- assert_no_unresolved_template_tokens_in_yaml_values(sync_kettle_config_env_overrides(content, env), KETTLE_CONFIG_PATH)
12692
+ assert_no_unresolved_template_tokens_in_yaml_values(sync_kettle_config_env_overrides(content, env, project_root: project_root), KETTLE_CONFIG_PATH)
12503
12693
  end
12504
12694
 
12505
12695
  def replace_kettle_config_bootstrap_project_emoji(content, emoji)
@@ -12517,6 +12707,14 @@ module Kettle
12517
12707
  raise Error, "Could not replace licenses block in .kettle-jem.yml bootstrap template"
12518
12708
  end
12519
12709
 
12710
+ def replace_kettle_config_bootstrap_platforms(content, platforms)
12711
+ platform_block = ["platforms:", *platforms.map { |platform| " - #{platform}" }].join("\n")
12712
+ updated = replace_yaml_node_lines(content, "platforms", "#{platform_block}\n")
12713
+ return updated unless updated == content
12714
+
12715
+ raise Error, "Could not replace platforms block in .kettle-jem.yml bootstrap template"
12716
+ end
12717
+
12520
12718
  def replace_kettle_config_bootstrap_rubyforum(content, rubyforum)
12521
12719
  updated = content
12522
12720
  family_tag = rubyforum[:family_tag].to_s
@@ -12556,7 +12754,7 @@ module Kettle
12556
12754
  nil
12557
12755
  end
12558
12756
 
12559
- def sync_kettle_config_env_overrides(content, env)
12757
+ def sync_kettle_config_env_overrides(content, env, project_root: nil)
12560
12758
  synced = KETTLE_CONFIG_ENV_SYNC_PATHS.reduce(content.to_s) do |updated, (path, env_key)|
12561
12759
  value = env_sync_value(env, env_key)
12562
12760
  next updated unless present_template_token_value?(value)
@@ -12568,9 +12766,36 @@ module Kettle
12568
12766
  synced = sync_kettle_config_internal_values(synced)
12569
12767
  synced = migrate_readme_logo_config(synced)
12570
12768
  synced = prune_legacy_kettle_config_keys(synced)
12769
+ synced = migrate_platforms_config(synced, project_root) if project_root
12571
12770
  sync_kettle_config_documentation_comments(synced)
12572
12771
  end
12573
12772
 
12773
+ # Injects the `platforms:` key into a pre-existing config that predates it.
12774
+ # The injected value is computed from repo evidence (existing Windows CI)
12775
+ # rather than defaulted, so a destination that already runs Windows CI does
12776
+ # not silently lose that coverage on its next re-template. Mirrors the
12777
+ # licenses bootstrap: config wins when present, evidence only fills an
12778
+ # absent key. The block lands adjacent to `engines:`, which it documents by
12779
+ # analogy, and carries its own documentation because the packaged config
12780
+ # documents every key with a preceding comment block.
12781
+ def migrate_platforms_config(content, project_root)
12782
+ config = YAML.safe_load(content.to_s, permitted_classes: [], aliases: true) || {}
12783
+ return content if config.key?("platforms")
12784
+
12785
+ platforms = detected_os_platforms(project_root)
12786
+ platforms = DEFAULT_PLATFORMS if platforms.empty?
12787
+ block = [
12788
+ *PLATFORMS_CONFIG_DOCUMENTATION,
12789
+ "platforms:",
12790
+ *platforms.map { |platform| " - #{platform}" },
12791
+ ""
12792
+ ].join("\n")
12793
+
12794
+ insert_yaml_block_after_top_level_key(content, "engines", block)
12795
+ rescue Psych::Exception
12796
+ content
12797
+ end
12798
+
12574
12799
  def normalize_kettle_config_optional_scalars(content)
12575
12800
  config = YAML.safe_load(content.to_s, permitted_classes: [], aliases: true) || {}
12576
12801
  return content unless config.key?("min_divergence_threshold")
@@ -12950,6 +13175,26 @@ module Kettle
12950
13175
  content
12951
13176
  end
12952
13177
 
13178
+ # Inserts a pre-indented block (comment lines plus a key with a block
13179
+ # sequence) immediately after the given top-level key's value span, so an
13180
+ # injected key lands adjacent to the analogous key it documents by
13181
+ # analogy. Falls back to appending at end of file when the anchor key is
13182
+ # absent. Only column-zero keys match: a nested key of the same name under
13183
+ # `files:` must not become the anchor.
13184
+ def insert_yaml_block_after_top_level_key(content, key, block)
13185
+ lines = content.to_s.lines
13186
+ yaml_mapping_nodes(content).each do |mapping|
13187
+ mapping.children.each_slice(2) do |key_node, value_node|
13188
+ next unless key_node.is_a?(Psych::Nodes::Scalar) && key_node.value.to_s == key.to_s
13189
+ next unless key_node.start_column.zero?
13190
+
13191
+ end_line = value_node.end_line
13192
+ return [*lines[0...end_line], block, *lines[end_line..].to_a].join
13193
+ end
13194
+ end
13195
+ "#{content.to_s.sub(/\n*\z/, "\n")}\n#{block}"
13196
+ end
13197
+
12953
13198
  def apply_kettle_config_bootstrap_profile(content, profile, gemspec_path)
12954
13199
  return content if profile.to_s.empty?
12955
13200
  return replace_yaml_scalar_path(content, %w[templates profile], FULL_TEMPLATE_PROFILE) if profile.to_s == FULL_TEMPLATE_PROFILE
@@ -13601,6 +13846,13 @@ module Kettle
13601
13846
  engines.map { |engine| engine.to_s.strip.downcase }.reject(&:empty?).uniq
13602
13847
  end
13603
13848
 
13849
+ def os_platforms_config(config)
13850
+ platforms = config["platforms"]
13851
+ return unless platforms.is_a?(Array)
13852
+
13853
+ platforms.map { |platform| platform.to_s.strip.downcase }.reject(&:empty?).uniq
13854
+ end
13855
+
13604
13856
  def funding_urls(project_root, package_name, funding_uri: nil, opencollective_disabled: false, open_collective_org: nil, enabled_platforms: nil)
13605
13857
  urls = [funding_uri]
13606
13858
  path = File.join(project_root, ".github", "FUNDING.yml")
@@ -14419,6 +14671,10 @@ module Kettle
14419
14671
  package.fetch(:name).to_s
14420
14672
  ),
14421
14673
  "KJ|CI:EXEC_CMD" => facts.dig(:ci, :exec_cmd).to_s,
14674
+ "KJ|CI:OS_MATRIX_ENTRIES" => github_actions_current_os_matrix_entries(facts),
14675
+ "KJ|CI:OS_TEST_STEPS" => github_actions_current_os_test_steps(facts),
14676
+ "KJ|README:PLATFORM_SUPPORT_TABLE" => readme_platform_support_table(facts),
14677
+ "KJ|RAKE:TOP_LEVEL_DOCS_TASK" => rake_top_level_docs_task_token(facts).rstrip,
14422
14678
  "KJ|GITHUB_ACTIONS:COVERAGE_UPLOAD_STEPS" => github_actions_coverage_steps(disabled_integrations: facts.dig(:integrations, :disabled))
14423
14679
  }.merge(
14424
14680
  rubocop_template_tokens(rubygems[:min_ruby], ruby_style: facts.fetch(:ruby_style, {}))
@@ -14459,27 +14715,27 @@ module Kettle
14459
14715
  def kettle_changelog_gemfile_dependency_token(package_name)
14460
14716
  return "" if package_name == "kettle-changelog"
14461
14717
 
14462
- <<~RUBY.chomp
14463
- # Release lockfile/build commands set this dependency-specific switch so
14464
- # the development tool cannot pull unpublished family gems into resolution.
14465
- kettle_changelog_skip = ENV.fetch("KETTLE_DEV_SKIP_CHANGELOG_DEPENDENCY", "false").downcase
14466
- kettle_changelog_skip = %w[true 1 yes on].include?(kettle_changelog_skip)
14467
- kettle_changelog_local = ENV.fetch("KETTLE_DEV_DEV", "false").downcase
14468
- kettle_changelog_local = !%w[false 0 no off].include?(kettle_changelog_local)
14469
- unless kettle_changelog_skip
14470
- if kettle_changelog_local
14471
- require "nomono/bundler"
14472
- eval_nomono_gems(
14473
- gems: ["kettle-changelog"],
14474
- prefix: "KETTLE_DEV",
14475
- path_env: "KETTLE_DEV_DEV",
14476
- root: ["src", "my", "kettle-dev"]
14477
- )
14478
- elsif Gem::Version.new(RUBY_VERSION) >= Gem::Version.new("4.0.0")
14479
- gem "kettle-changelog", #{template_managed_dependency("kettle-changelog").fetch(:requirements).map(&:inspect).join(", ")}
14480
- end
14481
- end
14482
- RUBY
14718
+ [
14719
+ "# Release lockfile/build commands set this dependency-specific switch so",
14720
+ "# the development tool cannot pull unpublished family gems into resolution.",
14721
+ "kettle_changelog_skip = ENV.fetch(\"KETTLE_DEV_SKIP_CHANGELOG_DEPENDENCY\", \"false\").downcase",
14722
+ "kettle_changelog_skip = %w[true 1 yes on].include?(kettle_changelog_skip)",
14723
+ "kettle_changelog_local = ENV.fetch(\"KETTLE_DEV_DEV\", \"false\").downcase",
14724
+ "kettle_changelog_local = !%w[false 0 no off].include?(kettle_changelog_local)",
14725
+ "unless kettle_changelog_skip",
14726
+ " if kettle_changelog_local",
14727
+ indent_source(nomono_bundler_bootstrap("../../Gemfile.lock"), 4),
14728
+ " eval_nomono_gems(",
14729
+ " gems: [\"kettle-changelog\"],",
14730
+ " prefix: \"KETTLE_DEV\",",
14731
+ " path_env: \"KETTLE_DEV_DEV\",",
14732
+ " root: [\"src\", \"my\", \"kettle-dev\"]",
14733
+ " )",
14734
+ " elsif Gem::Version.new(RUBY_VERSION) >= Gem::Version.new(\"4.0.0\")",
14735
+ %( gem "kettle-changelog", #{template_managed_dependency("kettle-changelog").fetch(:requirements).map(&:inspect).join(", ")}),
14736
+ " end",
14737
+ "end"
14738
+ ].join("\n")
14483
14739
  end
14484
14740
 
14485
14741
  def readme_title_token(package, rubygems)
@@ -14492,6 +14748,68 @@ module Kettle
14492
14748
  "#{package_name} / #{namespace}"
14493
14749
  end
14494
14750
 
14751
+ # The README platform tier table, generated from the same facts that drive
14752
+ # the workflows, so it cannot drift from CI again (spec slice 1038). Rows
14753
+ # mirror the generated workflow set: versioned MRI workflows are Linux-only,
14754
+ # the current.yml OS matrix covers the declared platforms for MRI current,
14755
+ # and every engine lane outside current.yml is Linux-only. Engine capability
14756
+ # notes come from Kettle::Rb::PlatformSupport and carry their citation.
14757
+ def readme_platform_support_table(facts)
14758
+ engines = facts.dig(:rubygems, :engines)
14759
+ engines = DEFAULT_ENGINES if engines.nil? || engines.empty?
14760
+ platforms = facts.dig(:ci, :platforms)
14761
+ platforms = DEFAULT_PLATFORMS if platforms.nil? || platforms.empty?
14762
+ ruby_versions = facts.dig(:ci, :ruby_versions).to_a.map(&:to_s).reject(&:empty?)
14763
+
14764
+ parallel = "Parallel support"
14765
+ unsupported = "Unsupported"
14766
+ rows = []
14767
+
14768
+ unless ruby_versions.empty?
14769
+ rows << [
14770
+ "MRI #{ruby_versions.first}-#{ruby_versions.last}",
14771
+ parallel, unsupported, unsupported,
14772
+ "Versioned Ruby workflows run on Linux."
14773
+ ]
14774
+ end
14775
+ rows << [
14776
+ "MRI current",
14777
+ parallel,
14778
+ platforms.include?("macos") ? parallel : unsupported,
14779
+ platforms.include?("windows") ? parallel : unsupported,
14780
+ "Current Ruby workflow covers the declared OS platforms."
14781
+ ]
14782
+ rows << [
14783
+ "MRI head",
14784
+ parallel, unsupported, unsupported,
14785
+ "CI is allowed to fail while Ruby is in development."
14786
+ ]
14787
+
14788
+ (engines - ["ruby"]).each do |engine|
14789
+ label = (engine == "jruby") ? "JRuby" : "TruffleRuby"
14790
+ windows_entry = Kettle::Rb::PlatformSupport.engine_os_entry(engine, "windows")
14791
+ note = if windows_entry && windows_entry.support == "unsupported"
14792
+ "#{windows_entry.note} ([source](#{windows_entry.citation}))"
14793
+ else
14794
+ "Engine workflows run on Linux; macOS and Windows are not exercised by CI."
14795
+ end
14796
+ rows << ["#{label} (all CI lanes)", parallel, unsupported, unsupported, note]
14797
+ end
14798
+
14799
+ [
14800
+ "Support tiers describe what this project's CI actually exercises, not what may",
14801
+ "work in an untested environment:",
14802
+ "",
14803
+ "- **Parallel support**: CI runs the test suite with multiple workers available.",
14804
+ "- **Unsupported**: the engine/platform combination is not covered by CI and is",
14805
+ " not a project compatibility claim.",
14806
+ "",
14807
+ "| Ruby engine/version | Linux | macOS | Windows | Notes |",
14808
+ "|---------------------|-------|-------|---------|-------|",
14809
+ *rows.map { |row| "| #{row.join(" | ")} |" }
14810
+ ].join("\n")
14811
+ end
14812
+
14495
14813
  def readme_dev_test_stack_table(package_name)
14496
14814
  rows = README_DEV_TEST_STACK_GEMS.reject { |gem| gem.fetch(:name) == package_name.to_s }.map do |gem|
14497
14815
  name = gem.fetch(:name)
@@ -14761,7 +15079,7 @@ module Kettle
14761
15079
  "[sm-family-fixtures]: https://github.com/structuredmerge/structuredmerge-fixtures",
14762
15080
  "[sm-family-go]: https://github.com/structuredmerge/structuredmerge-go",
14763
15081
  "[sm-family-ruby]: https://github.com/structuredmerge/structuredmerge-ruby",
14764
- "[sm-family-rust]: https://github.com/structuredmerge/structuredmerge-rust",
15082
+ "[sm-family-rust]: https://github.com/structuredmerge/structuredmerge",
14765
15083
  "[sm-family-typescript]: https://github.com/structuredmerge/structuredmerge-typescript"
14766
15084
  ].join("\n")
14767
15085
  end
@@ -15170,7 +15488,7 @@ module Kettle
15170
15488
  compact_hash(
15171
15489
  freeze_token: config.dig("defaults", "freeze_token").to_s.empty? ? "kettle-jem" : config.dig("defaults", "freeze_token").to_s,
15172
15490
  kettle_jem_version: VERSION,
15173
- kettle_jem_dependency_arguments: kettle_jem_dependency_requirements.map(&:inspect).join(", "),
15491
+ kettle_jem_dependency_arguments: kettle_jem_template_dependency_requirements(env: env).map(&:inspect).join(", "),
15174
15492
  template_run_date: run_timestamp.strftime("%Y-%m-%d"),
15175
15493
  template_run_year: run_timestamp.year.to_s,
15176
15494
  kettle_dev_local_gems: kettle_dev_local_gems(config),
@@ -15314,7 +15632,7 @@ module Kettle
15314
15632
  dev_env = "#{prefix}_DEV"
15315
15633
  root_literal = ruby_array_literal(direct_sibling_nomono_root_parts(workspace_slug, repository))
15316
15634
  word_array = names.map { |name| " #{name}" }.join("\n")
15317
- nomono_loader = %(require "nomono/bundler")
15635
+ nomono_bootstrap = nomono_bundler_bootstrap("Gemfile.lock")
15318
15636
 
15319
15637
  blocks << <<~RUBY.rstrip
15320
15638
  # Direct sibling dependencies (env-switched via #{dev_env})
@@ -15331,7 +15649,7 @@ module Kettle
15331
15649
  ENV.fetch("K_JEM_TEMPLATING", "false").casecmp("true").zero?)
15332
15650
  direct_sibling_dev_was_set = ENV.key?("#{dev_env}")
15333
15651
  direct_sibling_dev_original = ENV.fetch("#{dev_env}", nil)
15334
- #{nomono_loader}
15652
+ #{nomono_bootstrap}
15335
15653
  begin
15336
15654
  ENV["#{dev_env}"] = File.expand_path("..", __dir__) if direct_sibling_templating && !direct_sibling_local
15337
15655
 
@@ -15518,7 +15836,7 @@ module Kettle
15518
15836
  end
15519
15837
 
15520
15838
  def kettle_dev_local_gems(config)
15521
- gems = %w[kettle-dev kettle-family kettle-test kettle-soup-cover kettle-changelog]
15839
+ gems = %w[kettle-dev kettle-family kettle-rb kettle-test kettle-soup-cover kettle-changelog]
15522
15840
  plugin_names = PluginLoader.normalize_plugin_names(plugin_names_from_config(config))
15523
15841
  gems.concat(plugin_names.select { |plugin_name| plugin_name.start_with?("kettle-") })
15524
15842
  gems.uniq.join(" ")
@@ -15527,7 +15845,7 @@ module Kettle
15527
15845
  def main_gemfile_kettle_family_gem(package_name)
15528
15846
  return "" if package_name.to_s == "kettle-family"
15529
15847
 
15530
- %(gem "kettle-family", "~> 1.3", ">= 1.3.1"\n)
15848
+ %(gem "kettle-family", "~> 1.3", ">= 1.3.10"\n)
15531
15849
  end
15532
15850
 
15533
15851
  def main_gemfile_nomono_bootstrap(package_name)
@@ -15540,25 +15858,40 @@ module Kettle
15540
15858
  end
15541
15859
 
15542
15860
  def nomono_gemfile_declaration
15543
- %(gem "nomono", "~> 1.1", ">= 1.1.5", require: false # ruby >= 3.2.0)
15861
+ %(gem "nomono", "~> 1.1", ">= 1.1.7", require: false # ruby >= 3.2.0)
15544
15862
  end
15545
15863
 
15546
15864
  def local_gemfile_nomono_bootstrap(_package_name)
15865
+ nomono_bundler_bootstrap("../../Gemfile.lock")
15866
+ end
15867
+
15868
+ def nomono_bundler_bootstrap(lockfile_path)
15547
15869
  <<~RUBY.rstrip
15548
15870
  # Bootstrapping nomono here cannot rely on a plain `gem "nomono", ...` line.
15549
15871
  # Bundler records that dependency during Gemfile evaluation, but it does not
15550
15872
  # activate that exact version before the immediate `require "nomono/bundler"`.
15551
- nomono_activation_requirements = ["~> 1.1", ">= 1.1.5"]
15873
+ nomono_activation_requirements = ["~> 1.1", ">= 1.1.7"]
15552
15874
  nomono_requirement = Gem::Requirement.new(nomono_activation_requirements)
15553
15875
  nomono_already_activated = Gem.loaded_specs["nomono"]
15554
- nomono_lockfile = File.expand_path("../../Gemfile.lock", __dir__)
15555
- if !nomono_already_activated || !nomono_requirement.satisfied_by?(nomono_already_activated.version)
15876
+ nomono_lockfile = File.expand_path(#{lockfile_path.inspect}, __dir__)
15877
+ nomono_locked_spec = nil
15878
+ if File.file?(nomono_lockfile)
15556
15879
  require "bundler"
15557
- if File.file?(nomono_lockfile)
15558
- nomono_locked_spec = Bundler::LockfileParser
15559
- .new(Bundler.read_file(nomono_lockfile))
15560
- .specs
15561
- .find { |spec| spec.name == "nomono" }
15880
+ nomono_locked_spec = Bundler::LockfileParser
15881
+ .new(Bundler.read_file(nomono_lockfile))
15882
+ .specs
15883
+ .find { |spec| spec.name == "nomono" }
15884
+ end
15885
+ nomono_local_loader = if nomono_locked_spec && nomono_locked_spec.source.is_a?(Bundler::Source::Path)
15886
+ File.expand_path(
15887
+ File.join(nomono_locked_spec.source.path, "lib", "nomono", "bundler"),
15888
+ File.dirname(nomono_lockfile)
15889
+ )
15890
+ end
15891
+ if nomono_local_loader && File.file?("\#{nomono_local_loader}.rb")
15892
+ require nomono_local_loader
15893
+ else
15894
+ if !nomono_already_activated || !nomono_requirement.satisfied_by?(nomono_already_activated.version)
15562
15895
  nomono_locked_installed = nomono_locked_spec &&
15563
15896
  Gem::Specification.find_all_by_name("nomono").any? { |spec| spec.version == nomono_locked_spec.version }
15564
15897
  nomono_locked = nomono_locked_spec &&
@@ -15566,9 +15899,9 @@ module Kettle
15566
15899
  nomono_requirement.satisfied_by?(nomono_locked_spec.version)
15567
15900
  nomono_activation_requirements = ["= \#{nomono_locked_spec.version}"] if nomono_locked
15568
15901
  end
15902
+ Kernel.send(:gem, "nomono", *nomono_activation_requirements)
15903
+ require "nomono/bundler"
15569
15904
  end
15570
- Kernel.send(:gem, "nomono", *nomono_activation_requirements)
15571
- require "nomono/bundler"
15572
15905
  RUBY
15573
15906
  end
15574
15907
 
@@ -15791,7 +16124,10 @@ module Kettle
15791
16124
  next false if argument.to_s.empty?
15792
16125
 
15793
16126
  if call.name == :require_relative
15794
- File.expand_path(File.join(project_root, File.dirname(entrypoint_path), "#{argument}.rb")) == version_absolute_path
16127
+ Kettle::Dev::Paths.same?(
16128
+ File.join(project_root, File.dirname(entrypoint_path), "#{argument}.rb"),
16129
+ version_absolute_path
16130
+ )
15795
16131
  else
15796
16132
  argument == version_require_path
15797
16133
  end
@@ -17440,6 +17776,38 @@ module Kettle
17440
17776
  }
17441
17777
  end
17442
17778
 
17779
+ # Top-level `docs` task, emitted only for the profiles that need it.
17780
+ #
17781
+ # The gate is inverted from the obvious reading. At a monorepo root the
17782
+ # `family` namespace loads (the root has a `gems/` directory) and
17783
+ # `family:docs` exists, and there is no `docs/` directory, so `rake docs`
17784
+ # fails loudly with "Don't know how to build task 'docs'". That is an honest
17785
+ # error which points the reader at `family:docs`; adding a delegating task
17786
+ # there would only create a second path to behavior the family task already
17787
+ # owns.
17788
+ #
17789
+ # Everywhere else the failure is silent, and that is what this fixes. A
17790
+ # generated `docs/` directory makes Rake resolve the bare name `docs` as an
17791
+ # implicit file task whose target already exists, so `rake docs` exits 0
17792
+ # having regenerated nothing. Verified per profile: standalone and
17793
+ # monorepo-subgem both have `docs/` and no top-level `docs` task;
17794
+ # monorepo-root has neither.
17795
+ #
17796
+ # Document generation is owned by `yard`, which kettle-dev installs and
17797
+ # registers as a `default` prerequisite, so this delegates rather than
17798
+ # reimplementing it.
17799
+ def rake_top_level_docs_task_token(facts)
17800
+ return "" if monorepo_root_template_profile?(facts)
17801
+
17802
+ <<~RAKE
17803
+ # Without this, a generated docs/ directory makes Rake resolve the bare
17804
+ # name `docs` as an up-to-date file task, so `rake docs` exits 0 having
17805
+ # regenerated nothing.
17806
+ desc "Generate YARD documentation"
17807
+ task docs: "yard"
17808
+ RAKE
17809
+ end
17810
+
17443
17811
  def gemspec_template_facts(config)
17444
17812
  includes = Array(config.dig("gemspec", "package_files", "include"))
17445
17813
  .map { |path| path.to_s.strip }
@@ -18637,6 +19005,7 @@ module Kettle
18637
19005
  "KJ|RUBYFORUM:PROJECT_TAG" => bootstrap.dig(:rubyforum, :project_tag).to_s
18638
19006
  }
18639
19007
  recipe[:bootstrap_licenses] = Array(bootstrap[:licenses]).map(&:to_s).reject(&:empty?)
19008
+ recipe[:bootstrap_platforms] = Array(bootstrap[:platforms]).map(&:to_s).reject(&:empty?)
18640
19009
  recipe[:bootstrap_template_profile] = bootstrap[:template_profile].to_s unless bootstrap[:template_profile].to_s.empty?
18641
19010
  recipe[:bootstrap_gemspec_path] = bootstrap[:gemspec_path].to_s unless bootstrap[:gemspec_path].to_s.empty?
18642
19011
  recipe[:bootstrap_project_emoji] = bootstrap[:project_emoji].to_s unless bootstrap[:project_emoji].to_s.empty?
@@ -18956,6 +19325,27 @@ module Kettle
18956
19325
  (engines.nil? || engines.empty?) ? DEFAULT_ENGINES : engines
18957
19326
  end
18958
19327
 
19328
+ # CI OS families the destination supports, from the `platforms:` config key,
19329
+ # falling back to DEFAULT_PLATFORMS when the key is absent or empty. Named
19330
+ # to stay clear of the funding-platforms vocabulary (#funding_platform_enabled?).
19331
+ def enabled_os_platforms(config)
19332
+ platforms = os_platforms_config(config)
19333
+ (platforms.nil? || platforms.empty?) ? DEFAULT_PLATFORMS : platforms
19334
+ end
19335
+
19336
+ # Configured → detected → default. Facts are computed BEFORE the config
19337
+ # migration runs in the same templating pass, so a missing `platforms:` key
19338
+ # must fold repo evidence in here: generating from the default alone would
19339
+ # silently drop existing Windows CI coverage on the first run, even though
19340
+ # the migration writes the correct key in that same run. Mirrors
19341
+ # configured_or_detected_licenses.
19342
+ def ci_os_platforms_facts(kettle_config, project_root)
19343
+ platforms = os_platforms_config(kettle_config)
19344
+ platforms = detected_os_platforms(project_root) if platforms.nil? || platforms.empty?
19345
+ platforms = DEFAULT_PLATFORMS if platforms.nil? || platforms.empty?
19346
+ platforms
19347
+ end
19348
+
18959
19349
  def config_min_ruby(config)
18960
19350
  value = config.dig("rubygems", "min_ruby") || config["min_ruby"]
18961
19351
  version = value.to_s[/\d+\.\d+(?:\.\d+)?/]
@@ -19053,6 +19443,7 @@ module Kettle
19053
19443
  }
19054
19444
  gemfile_entry[:default] = true if entry[:default]
19055
19445
  gemfile_entry[:replaces] = entry[:replaces] unless entry.fetch(:replaces, []).empty?
19446
+ gemfile_entry.merge!(framework_matrix_security(framework_gem, entry))
19056
19447
  gemfile_entry
19057
19448
  end.uniq { |entry| entry.fetch(:path) }
19058
19449
  default_gemfiles = gemfiles.select { |entry| entry[:default] }
@@ -19238,6 +19629,32 @@ module Kettle
19238
19629
  "~> #{normalized}"
19239
19630
  end
19240
19631
 
19632
+ # Security data from kettle-rb (Kettle::Rb::GemFloors) for a framework
19633
+ # matrix version of a tracked gem. The series' security floor is added
19634
+ # when the version's requirement still admits the series' first release,
19635
+ # and advisories that no release in the series fixes are recorded so the
19636
+ # generated gemfile can warn about them.
19637
+ def framework_matrix_security(gem_name, entry)
19638
+ return {} unless Kettle::Rb::GemFloors.tracked?(gem_name)
19639
+
19640
+ series = Kettle::Rb::GemFloors.series_for(entry.fetch(:label))
19641
+ return {} unless series
19642
+
19643
+ security = {}
19644
+ first_release = Gem::Version.new("#{series}.0")
19645
+ floor = Kettle::Rb::GemFloors.floor(gem_name, series)
19646
+ requirement = Gem::Requirement.new(*entry.fetch(:requirement).split(/\s*,\s*/))
19647
+ if floor && Gem::Version.new(floor) > first_release && requirement.satisfied_by?(first_release)
19648
+ security[:security_floor] = floor
19649
+ end
19650
+ unpatched = Kettle::Rb::GemFloors.unpatched_advisories(gem_name, series)
19651
+ security[:unpatched_advisories] = unpatched unless unpatched.empty?
19652
+ security[:security_series] = series unless security.empty?
19653
+ security
19654
+ rescue ArgumentError
19655
+ {}
19656
+ end
19657
+
19241
19658
  def github_actions_coverage_config(config, env = ENV)
19242
19659
  workflows = config["workflows"]
19243
19660
  return {} unless workflows.is_a?(Hash)
@@ -19263,6 +19680,19 @@ module Kettle
19263
19680
  )
19264
19681
  end
19265
19682
 
19683
+ # The binstub the generated Windows-only test step invokes. The step
19684
+ # bypasses Appraisal and runs under gemfiles/current.gemfile, but the
19685
+ # command itself is the destination's own configured test command
19686
+ # (ci.exec_cmd) resolved to its binstub -- never a hardcoded binstub of
19687
+ # any one repo. The packaged template once hardcoded bin/turbo_tests2,
19688
+ # which was correct for exactly one destination and inconsistent even
19689
+ # there, since that destination's own exec_cmd is kettle-test.
19690
+ def github_actions_windows_exec_binstub(exec_cmd)
19691
+ token = exec_cmd.to_s.strip.split(/\s+/).last.to_s
19692
+ token = token.sub(%r{\Abin/}, "")
19693
+ token.empty? ? "kettle-test" : token
19694
+ end
19695
+
19266
19696
  # A generated workflow normally uses one command for every matrix entry.
19267
19697
  # Legacy engines occasionally need a narrower command while retaining the
19268
19698
  # rest of the generated workflow (setup, retries, cache keys, and pins).
@@ -19709,7 +20139,7 @@ module Kettle
19709
20139
  def github_actions_setup_ruby_steps(indent:)
19710
20140
  yaml = <<~YAML
19711
20141
  - name: Setup Ruby & RubyGems
19712
- uses: appraisal-rb/setup-ruby-flash@925395edf973d2dc0a629919f407f3547a03d4b5 # v2.1
20142
+ uses: appraisal-rb/setup-ruby-flash@6f8ad36ba7488db591541483afb5464d01e8861b # v2.14
19713
20143
  with:
19714
20144
  ruby-version: "${{ matrix.ruby }}"
19715
20145
  rubygems: "${{ matrix.rubygems }}"
@@ -19758,7 +20188,13 @@ module Kettle
19758
20188
  lines << %(ENV[#{key.inspect}] = #{value.inspect})
19759
20189
  end
19760
20190
  lines << ""
19761
- lines << %(gem #{gemfile.fetch(:gem).inspect}, #{gemfile.fetch(:requirement).inspect})
20191
+ unpatched = gemfile.fetch(:unpatched_advisories, [])
20192
+ unless unpatched.empty?
20193
+ lines << "# kettle-rb: no #{gemfile.fetch(:gem)} #{gemfile.fetch(:security_series)} release fixes #{unpatched.join(", ")}"
20194
+ end
20195
+ requirements = [gemfile.fetch(:requirement)]
20196
+ requirements << ">= #{gemfile.fetch(:security_floor)}" if gemfile[:security_floor]
20197
+ lines << %(gem #{[gemfile.fetch(:gem), *requirements].map(&:inspect).join(", ")})
19762
20198
  "#{lines.join("\n")}\n"
19763
20199
  end
19764
20200
 
@@ -19821,7 +20257,7 @@ module Kettle
19821
20257
  " uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1",
19822
20258
  "",
19823
20259
  " - name: Setup Ruby & RubyGems",
19824
- " uses: appraisal-rb/setup-ruby-flash@925395edf973d2dc0a629919f407f3547a03d4b5 # v2.1",
20260
+ " uses: appraisal-rb/setup-ruby-flash@6f8ad36ba7488db591541483afb5464d01e8861b # v2.14",
19825
20261
  " with:",
19826
20262
  " ruby-version: \"${{ matrix.ruby }}\"",
19827
20263
  " rubygems: \"${{ matrix.rubygems }}\"",
@@ -19873,6 +20309,61 @@ module Kettle
19873
20309
  update_github_actions_pins(updated)
19874
20310
  end
19875
20311
 
20312
+ # The current.yml OS matrix entries, generated from the destination's
20313
+ # declared CI OS families (facts[:ci][:platforms]). current.yml is
20314
+ # strategy: accept_template, so it is replaced wholesale and the matrix
20315
+ # must be generated from facts rather than pruned from a destination file
20316
+ # (spec slice 1038). Runner labels come from Kettle::Rb::PlatformSupport,
20317
+ # and an unknown family raises rather than silently dropping a lane, since
20318
+ # a typo'd family would otherwise produce no CI for that platform at all.
20319
+ def github_actions_current_os_matrix_entries(facts)
20320
+ platforms = facts.dig(:ci, :platforms)
20321
+ platforms = DEFAULT_PLATFORMS if platforms.nil? || platforms.empty?
20322
+ exec_cmd = facts.dig(:ci, :exec_cmd).to_s
20323
+
20324
+ platforms.map do |platform|
20325
+ label = Kettle::Rb::PlatformSupport.runner_label(platform)
20326
+ raise Error, "Unknown CI OS family in platforms config: #{platform.inspect} (expected one of #{Kettle::Rb::PlatformSupport::OS_FAMILIES.join(", ")})" unless label
20327
+
20328
+ [
20329
+ %( - ruby: "ruby"),
20330
+ %( appraisal: "current"),
20331
+ %( os: #{label}),
20332
+ %( exec_cmd: "#{exec_cmd}"),
20333
+ " rubygems: latest",
20334
+ " bundler: latest"
20335
+ ].join("\n")
20336
+ end.join("\n")
20337
+ end
20338
+
20339
+ # The current.yml test steps, generated from the same platforms facts. The
20340
+ # Windows-only guard and step exist only when windows is declared, so no
20341
+ # tautological `if:` survives in a linux+macos destination (spec slice
20342
+ # 1038). The Windows step bypasses Appraisal and invokes the destination's
20343
+ # own configured test command, resolved to its binstub from
20344
+ # facts[:ci][:exec_cmd] -- never a hardcoded binstub of any one repo.
20345
+ def github_actions_current_os_test_steps(facts)
20346
+ platforms = facts.dig(:ci, :platforms)
20347
+ platforms = DEFAULT_PLATFORMS if platforms.nil? || platforms.empty?
20348
+ windows = platforms.include?("windows")
20349
+ windows_label = Kettle::Rb::PlatformSupport.runner_label("windows")
20350
+
20351
+ lines = [" - name: Tests for ${{ matrix.os }} ${{ matrix.ruby }} via ${{ matrix.exec_cmd }}"]
20352
+ lines << %( if: matrix.os != '#{windows_label}') if windows
20353
+ lines << " run: bundle exec appraisal ${{ matrix.appraisal }} ${{ matrix.exec_cmd }}"
20354
+ return lines.join("\n") unless windows
20355
+
20356
+ binstub = github_actions_windows_exec_binstub(facts.dig(:ci, :exec_cmd))
20357
+ (lines + [
20358
+ "",
20359
+ " - name: Tests for #{windows_label} ${{ matrix.ruby }} via ruby bin/#{binstub}",
20360
+ %( if: matrix.os == '#{windows_label}'),
20361
+ " env:",
20362
+ " BUNDLE_GEMFILE: ${{ github.workspace }}/gemfiles/current.gemfile",
20363
+ " run: ruby -rbundler/setup bin/#{binstub}"
20364
+ ]).join("\n")
20365
+ end
20366
+
19876
20367
  def github_actions_push_branches_yaml(content, default_branch:, indent: " ")
19877
20368
  github_actions_push_branches(content, default_branch: default_branch).map do |branch|
19878
20369
  "#{indent}- #{branch.inspect}"
@@ -19970,7 +20461,7 @@ module Kettle
19970
20461
  steps << <<~YAML
19971
20462
  - name: Upload coverage to QLTY
19972
20463
  if: ${{ !env.ACT }}
19973
- uses: qltysh/qlty-action/coverage@08a0a862c159eae9b9003081da6663d96efef637 # v2.3.0
20464
+ uses: qltysh/qlty-action/coverage@c9b09987143d1e4ac955f4803c7bea742102feb5 # v2.4.0
19974
20465
  with:
19975
20466
  oidc: true
19976
20467
  files: coverage/lcov.info
@@ -19984,7 +20475,7 @@ module Kettle
19984
20475
  steps << <<~YAML
19985
20476
  - name: Upload coverage to CodeCov
19986
20477
  if: ${{ !env.ACT }}
19987
- uses: codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0
20478
+ uses: codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1
19988
20479
  with:
19989
20480
  use_oidc: true
19990
20481
  disable_search: true
@@ -20264,19 +20755,19 @@ module Kettle
20264
20755
  {
20265
20756
  "actions/checkout" => "actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1",
20266
20757
  "actions/cache" => "actions/cache@55cc8345863c7cc4c66a329aec7e433d2d1c52a9 # v6.1.0",
20267
- "appraisal-rb/setup-ruby-flash" => "appraisal-rb/setup-ruby-flash@925395edf973d2dc0a629919f407f3547a03d4b5 # v2.1",
20268
- "ruby/setup-ruby" => "ruby/setup-ruby@95ef2b042f9d7a56d8268cba8559e2842e2ad01b # v1.321.0",
20758
+ "appraisal-rb/setup-ruby-flash" => "appraisal-rb/setup-ruby-flash@6f8ad36ba7488db591541483afb5464d01e8861b # v2.14",
20759
+ "ruby/setup-ruby" => "ruby/setup-ruby@651ff0d62c5c79bed6d53ec518387aae20e72c1a # v1.329.0",
20269
20760
  "coverallsapp/github-action" => "coverallsapp/github-action@8d6379e14d29928660c4ba802d8e85393440b329 # v2.3.8",
20270
- "qltysh/qlty-action/coverage" => "qltysh/qlty-action/coverage@08a0a862c159eae9b9003081da6663d96efef637 # v2.3.0",
20271
- "codecov/codecov-action" => "codecov/codecov-action@fb8b3582c8e4def4969c97caa2f19720cb33a72f # v7.0.0",
20761
+ "qltysh/qlty-action/coverage" => "qltysh/qlty-action/coverage@c9b09987143d1e4ac955f4803c7bea742102feb5 # v2.4.0",
20762
+ "codecov/codecov-action" => "codecov/codecov-action@303a32d7a59b442fa8d48b6a1cc6825c09c847a5 # v7.1.1",
20272
20763
  "irongut/CodeCoverageSummary" => "irongut/CodeCoverageSummary@51cc3a756ddcd398d447c044c02cb6aa83fdae95 # v1.3.0",
20273
20764
  "marocchino/sticky-pull-request-comment" => "marocchino/sticky-pull-request-comment@5770ad5eb8f42dd2c4f34da00c94c5381e49af88 # v3.0.5",
20274
- "actions/upload-artifact" => "actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1",
20765
+ "actions/upload-artifact" => "actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2",
20275
20766
  "amancevice/setup-code-climate" => "amancevice/setup-code-climate@0daf2985a225e8ac15975b4d233010e94d65b76a # v2",
20276
20767
  "actions/dependency-review-action" => "actions/dependency-review-action@a1d282b36b6f3519aa1f3fc636f609c47dddb294 # v5.0.0",
20277
- "github/codeql-action/init" => "github/codeql-action/init@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0",
20278
- "github/codeql-action/autobuild" => "github/codeql-action/autobuild@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0",
20279
- "github/codeql-action/analyze" => "github/codeql-action/analyze@b96794f015dfd88f77b49b1c93e0fa7110f94c63 # v4.38.0",
20768
+ "github/codeql-action/init" => "github/codeql-action/init@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3",
20769
+ "github/codeql-action/autobuild" => "github/codeql-action/autobuild@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3",
20770
+ "github/codeql-action/analyze" => "github/codeql-action/analyze@24c54180a607b1449ed407dd24f251e4e9147c8d # v4.38.3",
20280
20771
  "pozil/auto-assign-issue" => "pozil/auto-assign-issue@af6beea6bdf1e8eb373f061c5bc168681fc6d011 # v4.0.1",
20281
20772
  "apache/skywalking-eyes/dependency" => "apache/skywalking-eyes/dependency@a196742f472feaffafea537ce5a2a4c3c53a8de4 # v0.9.0",
20282
20773
  "sarisia/actions-status-discord" => "sarisia/actions-status-discord@eb045afee445dc055c18d3d90bd0f244fd062708 # v1.16.0"