kettle-jem-appraisals 0.1.3 → 0.1.4

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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 41b13f22b6bfba6b96c553d89287e203328fab5aaba6b04502a1ad5dba319f8e
4
- data.tar.gz: a723d906fe86668fa7f19c44f86222d79fff8b6b934ea13832950045c2c64a0a
3
+ metadata.gz: a78ffff1b9845b058f35013adea659ae8a8b03f21c522494122255f1774f0913
4
+ data.tar.gz: 4411573fb6ebf9808efdf42d0d4be6496cdd18f07a9c637d326075a5314c2f14
5
5
  SHA512:
6
- metadata.gz: eab209704e695bc8f124fd096acf0b5e39b50a952d8c137d4b6399925c09e9817dd5bdf67e1c5fcd2d6e56516b7c9b5c2a6c9dc9da122689c4ee0dea0f350f83
7
- data.tar.gz: 2cc5cc8622f06648161a72fa5abb35924cc030a9f334ba69cebecb5a2e59c93a52d749fcc6fc1eb2101c5c421bcac52b298a6b6a686d85c821661042fdecbf2b
6
+ metadata.gz: c9aa86d8864d7204613bf89db1f78811b944d7cbe842f2f5e6c4bd95448bbb500bcfcd201c6e5519532a85fa9acf3974592f642651f793fd7317db80f38823df
7
+ data.tar.gz: e07bd405386de50eb128ab77984ae8eb47786f80c180fa3a8e8e8ba76503e8d3bbb306906eb9cffb25166ffbb9fb30cd06a9296c4581d606792a49d8f89bd130
checksums.yaml.gz.sig CHANGED
Binary file
data/CHANGELOG.md CHANGED
@@ -30,6 +30,23 @@ Please file a bug if you notice a violation of semantic versioning.
30
30
 
31
31
  ### Security
32
32
 
33
+ ## [0.1.4] - 2026-09-14
34
+
35
+ - TAG: [v0.1.4][0.1.4t]
36
+ - COVERAGE: 94.54% -- 848/897 lines in 15 files
37
+ - BRANCH COVERAGE: 78.67% -- 225/286 branches in 15 files
38
+ - 91.86% documented
39
+
40
+ ### Added
41
+
42
+ - Generate `gemfiles/modular/activerecord_support.gemfile` (sqlite3 1.4.x) and `activerecord_support_modern.gemfile` (sqlite3 >= 2.1) with JRuby JDBC adapter blocks when `activerecord` is a tier1 gem, and wire the matching one into each generated appraisal by ActiveRecord version; opt out with `appraisal_matrix.activerecord_support: false`.
43
+ - Document collapsing onto standard `ruby-X-Y` appraisals (intentional duplicate avoidance), collapse policies, and shared `appraisal_gemfiles`.
44
+
45
+ ### Fixed
46
+
47
+ - Read and write `appraisal_matrix` in `.structuredmerge/kettle-jem.yml` (the file kettle-jem uses), falling back to a legacy root `.kettle-jem.yml`.
48
+ - Preserve comments and all other settings when writing the config; only changed top-level sections are rewritten.
49
+
33
50
  ## [0.1.3] - 2026-09-11
34
51
 
35
52
  - TAG: [v0.1.3][0.1.3t]
@@ -177,7 +194,9 @@ Please file a bug if you notice a violation of semantic versioning.
177
194
  - kettle-jem-template-20260802-001 - Devcontainer JSON files now merge as JSONC,
178
195
  preserving comments and trailing commas during template updates.
179
196
 
180
- [Unreleased]: https://github.com/appraisal-rb/kettle-jem-appraisals/compare/v0.1.3...HEAD
197
+ [Unreleased]: https://github.com/appraisal-rb/kettle-jem-appraisals/compare/v0.1.4...HEAD
198
+ [0.1.4]: https://github.com/appraisal-rb/kettle-jem-appraisals/compare/v0.1.3...v0.1.4
199
+ [0.1.4t]: https://github.com/appraisal-rb/kettle-jem-appraisals/releases/tag/v0.1.4
181
200
  [0.1.3]: https://github.com/appraisal-rb/kettle-jem-appraisals/compare/v0.1.2...v0.1.3
182
201
  [0.1.3t]: https://github.com/appraisal-rb/kettle-jem-appraisals/releases/tag/v0.1.3
183
202
  [0.1.2]: https://github.com/appraisal-rb/kettle-jem-appraisals/compare/v0.1.1...v0.1.2
data/README.md CHANGED
@@ -65,7 +65,7 @@ targets **`appraisal`** (which does not support `eval_gemfile`, and only support
65
65
 
66
66
  Compatible with MRI Ruby 4.0.0+, and JRuby.
67
67
  CI workflows and Appraisals are generated for MRI Ruby 4.0.0+.
68
- This test floor is configured by `ruby.test_minimum` in `.kettle-jem.yml` and
68
+ This test floor is configured by `ruby.test_minimum` in `.structuredmerge/kettle-jem.yml` and
69
69
  may be higher than the gem's runtime compatibility floor when legacy Rubies are
70
70
  not practical for the current toolchain.
71
71
 
@@ -144,8 +144,16 @@ gem install kettle-jem-appraisals
144
144
 
145
145
  ## ⚙️ Configuration
146
146
 
147
- Configuration lives in `.kettle-jem.yml` under the `appraisal_matrix` key.
148
- Running `--scaffold` creates a starter config from your gemspec.
147
+ Configuration lives under the `appraisal_matrix` key of the project's
148
+ [kettle-jem](https://github.com/kettle-dev/kettle-jem) configuration file,
149
+ `.structuredmerge/kettle-jem.yml` — the same file kettle-jem reads when templating.
150
+ Projects that still have a legacy root `.kettle-jem.yml` (and no
151
+ `.structuredmerge/kettle-jem.yml`) are read from there instead.
152
+ Running `--scaffold` creates a starter `appraisal_matrix` from your gemspec.
153
+
154
+ Writes are surgical: `--scaffold` and `--resolve` only rewrite the top-level
155
+ sections they change (normally just `appraisal_matrix`), so comments and every
156
+ other kettle-jem setting in the file are preserved.
149
157
 
150
158
  ### Config schema
151
159
 
@@ -162,6 +170,19 @@ appraisal_matrix:
162
170
  # Command to run in CI for each appraisal entry (default: "rake spec")
163
171
  exec_cmd: "rake spec"
164
172
 
173
+ # How generated entries collapse onto kettle-jem's standard ruby-X-Y
174
+ # appraisals (default: unique). See "Collapsing onto standard appraisals".
175
+ standard_appraisal_role: runtime_dependency
176
+
177
+ # Shared support gemfiles evaluated by every generated entry (and injected
178
+ # by kettle-jem into standard test appraisals). See "Shared support gemfiles".
179
+ appraisal_gemfiles:
180
+ - modular/combustion.gemfile
181
+
182
+ # Generate and wire ActiveRecord database support gemfiles when activerecord
183
+ # is a tier1 gem (default: true). See "ActiveRecord database support".
184
+ activerecord_support: true
185
+
165
186
  gems:
166
187
  # Tier 1: primary dependencies whose versions drive the matrix.
167
188
  # Each tier1 version is assigned to its optimal Ruby bucket.
@@ -311,6 +332,82 @@ Common gem abbreviations are applied automatically:
311
332
 
312
333
  Examples: `kja-ar-7-1-oa-2-1-r3`, `kja-mail-2-8-r3` (tier1-only)
313
334
 
335
+ #### Collapsing onto standard appraisals
336
+
337
+ kettle-jem already generates one standard appraisal per supported Ruby series
338
+ (`ruby-2-7`, `ruby-3-1`, …), and each of those already has a CI job. A generated
339
+ `kja-*` entry whose Ruby bucket maps to one of those series would otherwise be a
340
+ **duplicate** appraisal (and duplicate CI job) for the same Ruby. To avoid that,
341
+ kettle-jem-appraisals intentionally **collapses** one generated entry per
342
+ standard series onto the standard name: the entry keeps its `kja-*` name
343
+ internally, but is emitted as `appraise "ruby-X-Y"`. kettle-jem then merges that
344
+ block with its own templated `ruby-X-Y` block, so the standard job runs against
345
+ the generated dependency versions. Every entry that is not collapsed keeps its
346
+ `kja-*` name, and stale-file cleanup still keys off the `kja-` prefix.
347
+
348
+ Which entry collapses is controlled by the collapse policy, read from
349
+ `appraisal_matrix.collapse.standard_appraisals`, `standard_appraisal_role`, or
350
+ `standard_appraisal_collapse` (first one set wins):
351
+
352
+ | Policy | Accepted values | Behavior |
353
+ |-------------------------|--------------------------------------------------------------|-----------------------------------------------------------------------------------------------------------------------------|
354
+ | **unique** (default) | anything not listed below | Collapse only when a single generated entry maps to the series; ambiguous series keep all entries as `kja-*`. |
355
+ | **required** | `required`, `runtime`, `runtime_dependency`, `dependency`, `substrate` | Always collapse one entry per series — the one with the highest tier1 (then tier2) version. Use when the tier1 gem is a runtime dependency every standard job needs. |
356
+ | **none** | `none`, `false`, `off`, `no`, `never` | Never collapse; every entry stays `kja-*`. |
357
+
358
+ For example, with `standard_appraisal_role: runtime_dependency` and an
359
+ `activerecord` tier1, the `r3.1` bucket's ActiveRecord 7.2 entry is emitted as
360
+ `ruby-3-1`, while an extra ActiveRecord 7.0 entry for the same bucket stays
361
+ `kja-ar-7-0-r2`.
362
+
363
+ ### Shared support gemfiles
364
+
365
+ `appraisal_matrix.appraisal_gemfiles` (alias: `appraisal_eval_gemfiles`) lists
366
+ modular gemfiles that every generated entry should `eval_gemfile`, such as
367
+ test-only fixtures (`modular/combustion.gemfile`). Paths may be given with or
368
+ without the leading `gemfiles/`. kettle-jem reads the same key (or
369
+ `workflows.standard_appraisal_gemfiles`) to inject those gemfiles into its
370
+ standard test appraisals, so collapsed and non-collapsed jobs share them.
371
+
372
+ kettle-jem's `test_bundle.gemfiles` is separate: it controls the root
373
+ `Gemfile` used for local development and the locked-deps workflow, and is not
374
+ changed by `appraisal_matrix`.
375
+
376
+ ### ActiveRecord database support
377
+
378
+ This gem's primary use case is gems that are tested against a spread of
379
+ ActiveRecord versions on MRI, JRuby, and TruffleRuby. Those test suites need a
380
+ database driver whose gem differs per engine and per ActiveRecord version:
381
+ `sqlite3` on MRI/TruffleRuby, `activerecord-jdbcsqlite3-adapter` (plus
382
+ `bigdecimal`) on JRuby. When `activerecord` is a tier1 gem, `--resolve`:
383
+
384
+ - writes `gemfiles/modular/activerecord_support.gemfile` (`sqlite3` 1.4.x, for
385
+ ActiveRecord < 7.2) and `gemfiles/modular/activerecord_support_modern.gemfile`
386
+ (`sqlite3` >= 2.1, for ActiveRecord >= 7.2), each with a JRuby platform block
387
+ for the JDBC adapter, whose release series tracks ActiveRecord so Bundler picks
388
+ the matching adapter;
389
+ - adds the matching support gemfile to each generated entry, based on that
390
+ entry's ActiveRecord version (unless `appraisal_gemfiles` already lists one of
391
+ them).
392
+
393
+ Existing support gemfiles that do not carry the `# Generated by
394
+ kettle-jem-appraisals` header are treated as hand-maintained and left untouched.
395
+ Set `activerecord_support: false` to opt out entirely (for example, when the
396
+ suite uses PostgreSQL). Point the root `Gemfile` at the modern support gemfile
397
+ via kettle-jem's `test_bundle.gemfiles`, and select the JDBC adapter in your spec
398
+ helper on JRuby:
399
+
400
+ ```ruby
401
+ require "active_record"
402
+ if RUBY_PLATFORM == "java"
403
+ require "activerecord-jdbcsqlite3-adapter"
404
+ else
405
+ require "sqlite3"
406
+ end
407
+ adapter = (RUBY_PLATFORM == "java" && ActiveRecord.version < Gem::Version.new("7.2")) ? "jdbcsqlite3" : "sqlite3"
408
+ ActiveRecord::Base.establish_connection(adapter: adapter, database: ":memory:")
409
+ ```
410
+
314
411
  ### Optimal bucket assignment
315
412
 
316
413
  Rather than cross-producting every gem version with every Ruby series,
@@ -331,7 +428,7 @@ Example with `activerecord`:
331
428
 
332
429
  ### Two-step workflow
333
430
 
334
- **Step 1 — Scaffold** reads your gemspec and populates `.kettle-jem.yml`:
431
+ **Step 1 — Scaffold** reads your gemspec and populates `appraisal_matrix` in `.structuredmerge/kettle-jem.yml`:
335
432
 
336
433
  ```sh
337
434
  kettle-jem-appraisals --scaffold
@@ -659,7 +756,7 @@ Thanks for RTFM. ☺️
659
756
  [📌gitmoji]: https://gitmoji.dev
660
757
  [📌gitmoji-img]: https://img.shields.io/badge/gitmoji_commits-%20%F0%9F%98%9C%20%F0%9F%98%8D-34495e.svg?style=flat-square
661
758
  [🧮kloc]: https://www.youtube.com/watch?v=dQw4w9WgXcQ
662
- [🧮kloc-img]: https://img.shields.io/badge/KLOC-0.786-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
759
+ [🧮kloc-img]: https://img.shields.io/badge/KLOC-0.897-FFDD67.svg?style=for-the-badge&logo=YouTube&logoColor=blue
663
760
  [🔐security]: https://github.com/appraisal-rb/kettle-jem-appraisals/blob/main/SECURITY.md
664
761
  [🔐security-img]: https://img.shields.io/badge/security-policy-259D6C.svg?style=flat
665
762
  [📄copyright-notice-explainer]: https://opensource.stackexchange.com/questions/5778/why-do-licenses-such-as-the-mit-license-specify-a-single-year
@@ -0,0 +1,106 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+
5
+ module Kettle
6
+ module Jem
7
+ module Appraisals
8
+ # Generates the modular gemfiles that give ActiveRecord test suites a
9
+ # database driver on every engine: +sqlite3+ on MRI/TruffleRuby and
10
+ # +activerecord-jdbcsqlite3-adapter+ (plus +bigdecimal+) on JRuby.
11
+ #
12
+ # Two variants exist because +sqlite3+ 2.x is only supported by
13
+ # ActiveRecord >= 7.2, while older ActiveRecord needs the 1.4 series.
14
+ # The JDBC adapter's release series tracks ActiveRecord (52.x, 61.x, 72.x…),
15
+ # so an open requirement lets Bundler pick the adapter matching the
16
+ # resolved ActiveRecord.
17
+ #
18
+ # Files that exist without the {GENERATED_HEADER} are treated as
19
+ # hand-maintained and are never overwritten.
20
+ #
21
+ # @example
22
+ # generator = ActiveRecordSupportGemfileGenerator.new(base_dir: "/path/to/gem")
23
+ # generator.generate
24
+ # generator.eval_path_for("7.1.6") #=> "modular/activerecord_support.gemfile"
25
+ class ActiveRecordSupportGemfileGenerator
26
+ # @return [String] header marking a support gemfile as generated (and safe to regenerate)
27
+ GENERATED_HEADER = "# Generated by kettle-jem-appraisals"
28
+
29
+ # @return [String] support gemfile for ActiveRecord below {MODERN_ACTIVERECORD}
30
+ LEGACY_PATH = "gemfiles/modular/activerecord_support.gemfile"
31
+
32
+ # @return [String] support gemfile for ActiveRecord at or above {MODERN_ACTIVERECORD}
33
+ MODERN_PATH = "gemfiles/modular/activerecord_support_modern.gemfile"
34
+
35
+ # @return [Gem::Version] first ActiveRecord release that supports sqlite3 2.x
36
+ MODERN_ACTIVERECORD = Gem::Version.new("7.2")
37
+
38
+ # @return [Hash{String => Hash}] per-variant sqlite3 requirements and description
39
+ VARIANTS = {
40
+ LEGACY_PATH => {sqlite3: [">= 1.4", "< 1.5"], label: "ActiveRecord < 7.2"},
41
+ MODERN_PATH => {sqlite3: [">= 2.1"], label: "ActiveRecord >= 7.2"}
42
+ }.freeze
43
+
44
+ class << self
45
+ # @param path [String] a gemfile path, with or without the leading +gemfiles/+
46
+ # @return [Boolean] whether +path+ names one of the ActiveRecord support gemfiles
47
+ def support_gemfile?(path)
48
+ normalized = path.to_s.strip.sub(%r{\Agemfiles/}, "")
49
+ VARIANTS.keys.any? { |variant| variant.delete_prefix("gemfiles/") == normalized }
50
+ end
51
+ end
52
+
53
+ # @return [String] absolute path to the project root
54
+ attr_reader :base_dir
55
+
56
+ # @param base_dir [String] path to the project root
57
+ def initialize(base_dir: Dir.pwd)
58
+ @base_dir = base_dir
59
+ end
60
+
61
+ # Writes both support gemfiles, skipping any hand-maintained file.
62
+ #
63
+ # @return [Hash{String => Symbol}] relative path => +:written+ or +:kept+
64
+ def generate
65
+ VARIANTS.each_with_object({}) do |(relative_path, variant), results|
66
+ absolute = File.join(base_dir, relative_path)
67
+ if File.exist?(absolute) && !File.read(absolute).include?(GENERATED_HEADER)
68
+ results[relative_path] = :kept
69
+ next
70
+ end
71
+
72
+ FileUtils.mkdir_p(File.dirname(absolute))
73
+ File.write(absolute, build_content(variant))
74
+ results[relative_path] = :written
75
+ end
76
+ end
77
+
78
+ # @param activerecord_version [String, Gem::Version] the entry's ActiveRecord version
79
+ # @return [String] the matching support gemfile, relative to +gemfiles/+ (for +eval_gemfile+)
80
+ def eval_path_for(activerecord_version)
81
+ path = (Gem::Version.new(activerecord_version.to_s) >= MODERN_ACTIVERECORD) ? MODERN_PATH : LEGACY_PATH
82
+ path.delete_prefix("gemfiles/")
83
+ end
84
+
85
+ private
86
+
87
+ def build_content(variant)
88
+ sqlite3 = [%("sqlite3"), *variant.fetch(:sqlite3).map(&:inspect), "platforms: [:ruby]"].join(", ")
89
+ <<~GEMFILE
90
+ # frozen_string_literal: true
91
+
92
+ #{GENERATED_HEADER}
93
+ # Database drivers for #{variant.fetch(:label)}: sqlite3 on MRI/TruffleRuby,
94
+ # activerecord-jdbcsqlite3-adapter on JRuby (its release series tracks ActiveRecord).
95
+ gem #{sqlite3}
96
+
97
+ platforms :jruby do
98
+ gem "activerecord-jdbcsqlite3-adapter", ">= 1.3"
99
+ gem "bigdecimal", ">= 1"
100
+ end
101
+ GEMFILE
102
+ end
103
+ end
104
+ end
105
+ end
106
+ end
@@ -13,7 +13,7 @@ module Kettle
13
13
  # or accepts explicit +--scaffold+ / +--resolve+ flags.
14
14
  #
15
15
  # In *scaffold* mode, reads the project gemspec, excludes standard-library
16
- # gems, and writes a skeleton +.kettle-jem.yml+ with tier1 candidates.
16
+ # gems, and writes a skeleton +appraisal_matrix+ into +.structuredmerge/kettle-jem.yml+ with tier1 candidates.
17
17
  #
18
18
  # In *resolve* mode, queries RubyGems for version data, assigns gem
19
19
  # versions to Ruby-series buckets, generates modular gemfiles, an
@@ -26,8 +26,11 @@ module Kettle
26
26
  # cli = Kettle::Jem::Appraisals::CLI.new(["--scaffold"], project_dir: "/path/to/gem")
27
27
  # cli.run
28
28
  class CLI
29
- # @return [String] name of the per-project YAML configuration file
30
- CONFIG_FILE = ".kettle-jem.yml"
29
+ # @return [String] canonical per-project kettle-jem configuration file (holds +appraisal_matrix+)
30
+ CONFIG_FILE = Kettle::Jem::KETTLE_CONFIG_PATH
31
+
32
+ # @return [String] legacy per-project configuration file, read when {CONFIG_FILE} is absent
33
+ LEGACY_CONFIG_FILE = Kettle::Jem::LEGACY_KETTLE_CONFIG_PATH
31
34
 
32
35
  # @return [String] top-level key in the YAML config that holds the appraisal matrix
33
36
  APPRAISAL_MATRIX_KEY = "appraisal_matrix"
@@ -125,10 +128,10 @@ module Kettle
125
128
  config[APPRAISAL_MATRIX_KEY] = matrix
126
129
  write_config(config)
127
130
 
128
- puts " ✅ Wrote scaffold to #{CONFIG_FILE}"
131
+ puts " ✅ Wrote scaffold to #{config_file.relative_path}"
129
132
  puts ""
130
133
  puts " Next steps:"
131
- puts " 1. Edit #{CONFIG_FILE} — arrange gems into tier1 and tier2"
134
+ puts " 1. Edit #{config_file.relative_path} — arrange gems into tier1 and tier2"
132
135
  puts " 2. Run `kettle-jem-appraisals` again (or `--resolve`) to generate matrix"
133
136
  end
134
137
 
@@ -140,7 +143,7 @@ module Kettle
140
143
  config = load_config
141
144
  matrix = config[APPRAISAL_MATRIX_KEY]
142
145
  unless matrix
143
- warn " ❌ No #{APPRAISAL_MATRIX_KEY} in #{CONFIG_FILE}. Run --scaffold first."
146
+ warn " ❌ No #{APPRAISAL_MATRIX_KEY} in #{config_file.relative_path}. Run --scaffold first."
144
147
  exit(1)
145
148
  end
146
149
 
@@ -263,6 +266,7 @@ module Kettle
263
266
  sub_resolver
264
267
  )
265
268
  annotate_extra_gemfiles(appraisal_entries, matrix_extra_gemfiles(matrix))
269
+ annotate_activerecord_support(appraisal_entries, tier1_gems, matrix)
266
270
  annotate_standard_appraisal_collapses(appraisal_entries, bucket_ranges, matrix)
267
271
 
268
272
  puts " 📊 Generated #{appraisal_entries.size} appraisal entries"
@@ -323,16 +327,16 @@ module Kettle
323
327
  puts " 🗑️ Removed #{stale.size} stale gemfile(s)"
324
328
  end
325
329
 
326
- def load_config
327
- path = File.join(project_dir, CONFIG_FILE)
328
- return {} unless File.exist?(path)
330
+ def config_file
331
+ @config_file ||= ConfigFile.new(project_dir: project_dir)
332
+ end
329
333
 
330
- YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
334
+ def load_config
335
+ config_file.load
331
336
  end
332
337
 
333
338
  def write_config(config)
334
- path = File.join(project_dir, CONFIG_FILE)
335
- File.write(path, YAML.dump(config))
339
+ config_file.write(config)
336
340
  end
337
341
 
338
342
  def find_gemspec
@@ -437,6 +441,7 @@ module Kettle
437
441
 
438
442
  entries << {
439
443
  name: GemAbbreviations.appraisal_name(t1_name, t1_ver, nil, nil, rs),
444
+ tier1_name: t1_name,
440
445
  tier1_version: t1_ver,
441
446
  tier1_gemfile: t1_gemfile,
442
447
  tier2_gemfile: nil,
@@ -479,6 +484,8 @@ module Kettle
479
484
 
480
485
  entries << {
481
486
  name: GemAbbreviations.appraisal_name(t1_name, t1_ver, t2_name, t2_ver, rs),
487
+ tier1_name: t1_name,
488
+ tier2_name: t2_name,
482
489
  tier1_version: t1_ver,
483
490
  tier2_version: t2_ver,
484
491
  tier1_gemfile: t1_gemfile,
@@ -591,7 +598,7 @@ module Kettle
591
598
  return if extra_gemfiles.empty?
592
599
 
593
600
  appraisal_entries.each do |entry|
594
- entry[:extra_gemfiles] = extra_gemfiles
601
+ entry[:extra_gemfiles] = (Array(entry[:extra_gemfiles]) + extra_gemfiles).uniq
595
602
  end
596
603
  end
597
604
 
@@ -602,6 +609,34 @@ module Kettle
602
609
  end.uniq
603
610
  end
604
611
 
612
+ # Generates the ActiveRecord database support gemfiles and adds the one
613
+ # matching each ActiveRecord entry's version to its extra gemfiles.
614
+ # Skipped when disabled via +activerecord_support+, when activerecord is not
615
+ # a tier1 gem, or when +appraisal_gemfiles+ already names a support gemfile.
616
+ def annotate_activerecord_support(appraisal_entries, tier1_gems, matrix)
617
+ return unless activerecord_support_enabled?(matrix)
618
+ return unless tier1_gems.any? { |gem_config| gem_config["name"] == "activerecord" }
619
+ return if matrix_extra_gemfiles(matrix).any? { |path| ActiveRecordSupportGemfileGenerator.support_gemfile?(path) }
620
+
621
+ generator = ActiveRecordSupportGemfileGenerator.new(base_dir: project_dir)
622
+ generator.generate.each do |path, status|
623
+ puts " 🗄️ #{(status == :written) ? "Wrote" : "Kept hand-maintained"} #{path}"
624
+ end
625
+ appraisal_entries.each do |entry|
626
+ next unless entry[:tier1_name] == "activerecord" && entry[:tier1_version]
627
+
628
+ support = generator.eval_path_for(entry[:tier1_version])
629
+ entry[:extra_gemfiles] = [support, *Array(entry[:extra_gemfiles])].uniq
630
+ end
631
+ end
632
+
633
+ def activerecord_support_enabled?(matrix)
634
+ value = matrix.is_a?(Hash) ? matrix["activerecord_support"] : nil
635
+ return true if value.nil?
636
+
637
+ !%w[false no off none never 0].include?(value.to_s.strip.downcase)
638
+ end
639
+
605
640
  def annotate_standard_appraisal_collapses(appraisal_entries, bucket_ranges, matrix = {})
606
641
  policy = standard_appraisal_collapse_policy(matrix)
607
642
  by_standard = appraisal_entries.group_by do |entry|
@@ -0,0 +1,132 @@
1
+ # frozen_string_literal: true
2
+
3
+ require "fileutils"
4
+ require "yaml"
5
+
6
+ module Kettle
7
+ module Jem
8
+ module Appraisals
9
+ # Reads and surgically updates the project's kettle-jem configuration file.
10
+ #
11
+ # The +appraisal_matrix+ lives in the same file kettle-jem uses
12
+ # (+.structuredmerge/kettle-jem.yml+). That file is heavily commented and
13
+ # holds many other settings, so writes only replace the top-level sections
14
+ # whose values changed, leaving comments and every other section intact.
15
+ #
16
+ # @example
17
+ # config_file = ConfigFile.new(project_dir: "/path/to/gem")
18
+ # config = config_file.load
19
+ # config["appraisal_matrix"]["resolved_at"] = Time.now.to_i
20
+ # config_file.write(config)
21
+ class ConfigFile
22
+ # @return [String] canonical config path, relative to the project root
23
+ CANONICAL_PATH = Kettle::Jem::KETTLE_CONFIG_PATH
24
+
25
+ # @return [String] legacy config path, read only when the canonical file is absent
26
+ LEGACY_PATH = Kettle::Jem::LEGACY_KETTLE_CONFIG_PATH
27
+
28
+ # @return [String] absolute path to the project root
29
+ attr_reader :project_dir
30
+
31
+ # @param project_dir [String] path to the project root
32
+ def initialize(project_dir:)
33
+ @project_dir = project_dir
34
+ end
35
+
36
+ # The config file to read and write: the canonical file when present,
37
+ # otherwise an existing legacy file, otherwise the canonical location.
38
+ #
39
+ # @return [String] absolute path
40
+ def path
41
+ canonical = File.join(project_dir, CANONICAL_PATH)
42
+ return canonical if File.exist?(canonical)
43
+
44
+ legacy = File.join(project_dir, LEGACY_PATH)
45
+ return legacy if File.exist?(legacy)
46
+
47
+ canonical
48
+ end
49
+
50
+ # @return [String] {#path} relative to the project root, for messages
51
+ def relative_path
52
+ File.expand_path(path).delete_prefix("#{File.expand_path(project_dir)}/")
53
+ end
54
+
55
+ # @return [Hash] the parsed config, or an empty Hash when the file does not exist
56
+ def load
57
+ return {} unless File.exist?(path)
58
+
59
+ YAML.safe_load_file(path, permitted_classes: [Symbol]) || {}
60
+ end
61
+
62
+ # Writes +config+, replacing only the top-level sections that differ from
63
+ # the file on disk. Creates the file (and its directory) when missing.
64
+ #
65
+ # @param config [Hash] the full desired config
66
+ # @return [void]
67
+ def write(config)
68
+ target = path
69
+ unless File.exist?(target)
70
+ FileUtils.mkdir_p(File.dirname(target))
71
+ File.write(target, YAML.dump(config))
72
+ return
73
+ end
74
+
75
+ content = File.read(target)
76
+ current = YAML.safe_load(content, permitted_classes: [Symbol]) || {}
77
+ config.each do |key, value|
78
+ next if current.key?(key) && current[key] == value
79
+
80
+ content = upsert_section(content, key.to_s, value)
81
+ end
82
+ File.write(target, content)
83
+ end
84
+
85
+ private
86
+
87
+ def upsert_section(content, key, value)
88
+ block = YAML.dump({key => value}).delete_prefix("---\n")
89
+ range = section_range(content, key)
90
+ return "#{content.rstrip}\n\n#{block}" unless range
91
+
92
+ lines = content.lines
93
+ [*lines[0...range.begin], block, *lines[range.end..]].join
94
+ end
95
+
96
+ # Line range (0-based, end-exclusive) of the top-level +key+ section.
97
+ # A parsed section runs up to the next top-level key, so trailing blank
98
+ # lines and comment lines (commented-out settings, or comments that
99
+ # introduce the next section) are trimmed back out of the range and kept.
100
+ def section_range(content, key)
101
+ require "yaml/merge"
102
+
103
+ analysis = Yaml::Merge::FileAnalysis.new(content)
104
+ raise ArgumentError, "could not parse #{relative_path} as YAML" unless analysis.valid?
105
+
106
+ body = analysis.documents.first&.body_node
107
+ return unless body&.mapping?
108
+
109
+ lines = content.lines
110
+ pairs = body.mapping_pairs
111
+ pairs.each_with_index do |pair, index|
112
+ next unless pair.key_name == key
113
+
114
+ start_index = pair.start_line.to_i - 1
115
+ next_pair = pairs[index + 1]
116
+ end_index = next_pair ? next_pair.start_line.to_i - 1 : lines.length
117
+ end_index -= 1 while end_index > start_index + 1 && trailing_trivia?(lines[end_index - 1])
118
+ return start_index...end_index
119
+ end
120
+
121
+ nil
122
+ end
123
+
124
+ # @return [Boolean] whether +line+ is blank or a comment (at any indentation)
125
+ def trailing_trivia?(line)
126
+ stripped = line.strip
127
+ stripped.empty? || stripped.start_with?("#")
128
+ end
129
+ end
130
+ end
131
+ end
132
+ end
@@ -6,7 +6,7 @@ module Kettle
6
6
  # Version namespace for this gem.
7
7
  module Version
8
8
  # Current gem version.
9
- VERSION = "0.1.3"
9
+ VERSION = "0.1.4"
10
10
  end
11
11
  # Current gem version exposed at the traditional constant location.
12
12
  VERSION = Version::VERSION # Traditional Constant Location
@@ -15,7 +15,9 @@ module Kettle
15
15
  path.to_s.sub(VAR_HOME_PREFIX, "/home")
16
16
  end
17
17
 
18
+ autoload :ActiveRecordSupportGemfileGenerator, "kettle/jem/appraisals/active_record_support_gemfile_generator"
18
19
  autoload :CLI, "kettle/jem/appraisals/cli"
20
+ autoload :ConfigFile, "kettle/jem/appraisals/config_file"
19
21
  autoload :GemAbbreviations, "kettle/jem/appraisals/gem_abbreviations"
20
22
  autoload :GemVersionResolver, "kettle/jem/appraisals/gem_version_resolver"
21
23
  autoload :MatrixBuilder, "kettle/jem/appraisals/matrix_builder"
@@ -10,8 +10,47 @@ module Kettle
10
10
  def self.generate: (Array[Hash[Symbol, untyped]] matrix) -> String
11
11
  end
12
12
 
13
+ class ActiveRecordSupportGemfileGenerator
14
+ GENERATED_HEADER: String
15
+ LEGACY_PATH: String
16
+ MODERN_PATH: String
17
+ MODERN_ACTIVERECORD: Gem::Version
18
+ VARIANTS: Hash[String, Hash[Symbol, untyped]]
19
+
20
+ attr_reader base_dir: String
21
+
22
+ def self.support_gemfile?: (String path) -> bool
23
+ def initialize: (?base_dir: String) -> void
24
+ def generate: () -> Hash[String, Symbol]
25
+ def eval_path_for: (String | Gem::Version activerecord_version) -> String
26
+
27
+ private
28
+
29
+ def build_content: (Hash[Symbol, untyped] variant) -> String
30
+ end
31
+
32
+ class ConfigFile
33
+ CANONICAL_PATH: String
34
+ LEGACY_PATH: String
35
+
36
+ attr_reader project_dir: String
37
+
38
+ def initialize: (project_dir: String) -> void
39
+ def path: () -> String
40
+ def relative_path: () -> String
41
+ def load: () -> Hash[String, untyped]
42
+ def write: (Hash[String, untyped] config) -> void
43
+
44
+ private
45
+
46
+ def upsert_section: (String content, String key, untyped value) -> String
47
+ def section_range: (String content, String key) -> Range[Integer]?
48
+ def trailing_trivia?: (String line) -> bool
49
+ end
50
+
13
51
  class CLI
14
52
  CONFIG_FILE: String
53
+ LEGACY_CONFIG_FILE: String
15
54
  APPRAISAL_MATRIX_KEY: String
16
55
  DEFAULT_FRESHNESS_TTL: Integer
17
56
 
@@ -28,8 +67,12 @@ module Kettle
28
67
  def has_versions?: (Hash[String, untyped] matrix) -> bool
29
68
  def run_scaffold: () -> void
30
69
  def run_resolve: () -> void
70
+ def config_file: () -> ConfigFile
31
71
  def load_config: () -> Hash[String, untyped]
32
72
  def write_config: (Hash[String, untyped] config) -> void
73
+ def annotate_extra_gemfiles: (Array[Hash[Symbol, untyped]] appraisal_entries, Array[String] extra_gemfiles) -> void
74
+ def annotate_activerecord_support: (Array[Hash[Symbol, untyped]] appraisal_entries, Array[Hash[String, untyped]] tier1_gems, Hash[String, untyped] matrix) -> void
75
+ def activerecord_support_enabled?: (Hash[String, untyped]? matrix) -> bool
33
76
  def find_gemspec: () -> String?
34
77
  def extract_runtime_deps: (String gemspec_path) -> Array[String]
35
78
  def fresh?: (Hash[String, untyped] matrix) -> bool
data.tar.gz.sig CHANGED
Binary file
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: kettle-jem-appraisals
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.3
4
+ version: 0.1.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Peter H. Boling
@@ -296,8 +296,10 @@ files:
296
296
  - exe/kettle-jem-appraisals
297
297
  - lib/kettle-jem-appraisals.rb
298
298
  - lib/kettle/jem/appraisals.rb
299
+ - lib/kettle/jem/appraisals/active_record_support_gemfile_generator.rb
299
300
  - lib/kettle/jem/appraisals/appraisals_generator.rb
300
301
  - lib/kettle/jem/appraisals/cli.rb
302
+ - lib/kettle/jem/appraisals/config_file.rb
301
303
  - lib/kettle/jem/appraisals/gem_abbreviations.rb
302
304
  - lib/kettle/jem/appraisals/gem_version_resolver.rb
303
305
  - lib/kettle/jem/appraisals/matrix_builder.rb
@@ -313,10 +315,10 @@ licenses:
313
315
  - AGPL-3.0-only
314
316
  metadata:
315
317
  homepage_uri: https://kettle-jem-appraisals.galtzo.com
316
- source_code_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/tree/v0.1.3
317
- changelog_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/blob/v0.1.3/CHANGELOG.md
318
+ source_code_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/tree/v0.1.4
319
+ changelog_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/blob/v0.1.4/CHANGELOG.md
318
320
  bug_tracker_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/issues
319
- documentation_uri: https://www.rubydoc.info/gems/kettle-jem-appraisals/0.1.3
321
+ documentation_uri: https://www.rubydoc.info/gems/kettle-jem-appraisals/0.1.4
320
322
  funding_uri: https://github.com/sponsors/pboling
321
323
  wiki_uri: https://github.com/appraisal-rb/kettle-jem-appraisals/wiki
322
324
  news_uri: https://www.railsbling.com/tags/kettle-jem-appraisals
metadata.gz.sig CHANGED
Binary file