From 905793c1570402be87e6013f0faf13cc094cd678 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Sat, 4 Jul 2026 16:57:58 +0200 Subject: [PATCH] feat(generator): version-aware migrations between docs-kit releases in --sync MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ## Summary `docs_kit:install --sync` was version-agnostic: it diffed a site against the current gem template with no notion of which docs-kit version the site was last synced from, so it could not apply *ordered* release-to-release migrations. This adds the mechanism (issue #41): - **Version stamp** — every install and `--sync` writes an inert `# docs-kit synced: vX.Y.Z` comment on the first line of the config initializer (the one file every site has). Injected into an existing, never-clobbered initializer; updated in place; idempotent. - **Migration + MigrationRegistry** — a Migration is an ordered, versioned transform (`to` = the release that introduced it) whose block returns the warn-only messages it couldn't safely automate. The registry selects the migrations in the half-open range `(last_synced, gem_version]` and runs them ascending, collecting warnings. - **--sync wiring** — reads the stamp BEFORE restamping, runs the applicable migrations, prints a "migration steps to apply by hand" checklist for anything warn-only, then restamps to the current version. Full (non-sync) installs don't migrate — they're a fresh scaffold, not an upgrade. The registry ships EMPTY at 1.0.x: the mechanism is the deliverable, and the first concrete `1.x → 1.y` transform is a one-line `Migration.new(...)` addition. ## Key invariant (surfaced by an end-to-end drive) `applicable` caps at the installed gem version (`upto:`, default `DocsKit::VERSION`). A migration targeting a version above the gem can't apply — the site can't have that release — and, without the cap, would re-fire on every sync after the site restamps to the gem version. The `upto` bound makes `--sync` idempotent. ## Backwards compatibility - A pre-feature site (un-stamped initializer) is treated as the earliest version (0.0.0) → every migration applies. Its config body is preserved byte-for-byte; only the inert stamp comment is prepended. - New sites via `docs-kit new` are stamped automatically (the template runs `docs_kit:install`). ## Test Coverage - spec/generators/migration_spec.rb — Migration value object (version coercion, block invocation, nil→[] warnings, natural version ordering). - spec/generators/migration_registry_spec.rb — applicable() gap logic (exclusive from, ascending, unknown/ahead sites, the upto ceiling) and migrate!() ordering + warning collection; .default ships empty. - spec/generators/install_generator_spec.rb — stamping (install + inject into existing + update stale + idempotent) and the --sync migration wiring (runs for a stamped/pre-feature site, prints warnings, no-op at current version, not on a full install). ## Verification - [x] bundle exec rspec passes (780 examples, 94.82% line coverage) - [x] bundle exec rubocop passes (no offenses) - [x] end-to-end drive of the real generator: stamp on install, gap-detected migration on sync, restamp, idempotent second sync Refs #41 Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX --- README.md | 25 +++ .../docs_kit/install/install_generator.rb | 61 +++++++ lib/generators/docs_kit/install/migration.rb | 32 ++++ .../docs_kit/install/migration_registry.rb | 57 +++++++ .../install/templates/docs_kit.rb.erb | 1 + spec/generators/install_generator_spec.rb | 153 +++++++++++++++++- spec/generators/migration_registry_spec.rb | 134 +++++++++++++++ spec/generators/migration_spec.rb | 48 ++++++ 8 files changed, 509 insertions(+), 2 deletions(-) create mode 100644 lib/generators/docs_kit/install/migration.rb create mode 100644 lib/generators/docs_kit/install/migration_registry.rb create mode 100644 spec/generators/migration_registry_spec.rb create mode 100644 spec/generators/migration_spec.rb diff --git a/README.md b/README.md index 7737c02..07b21d8 100644 --- a/README.md +++ b/README.md @@ -81,6 +81,31 @@ site-owned content (your `Doc` registry, your pages, your themed `application.tailwind.css`). Drop `--sync` to also (re)scaffold missing content files — Thor prompts before overwriting anything that exists. +### Version-aware migrations + +Every install and `--sync` stamps the docs-kit version it ran into your config +initializer, as an inert comment on the first line: + +```ruby +# docs-kit synced: v1.0.5 +# frozen_string_literal: true +Rails.application.config.to_prepare do + DocsKit.configure do |c| + # ... +``` + +On the next `--sync` after a `bundle update docs-kit`, the generator reads that +stamp, works out the gap to the newly-installed version, and runs the **ordered +release-to-release migrations** in between — renamed config knobs, changed route +shapes, restructured templates — in sequence, then restamps to the new version. +A site created before the stamp existed (no comment) is treated as the earliest +version, so it gets every migration. Migrations are **warn-only-safe** exactly +like the drift report: what a step can't safely automate it prints as a manual +checklist (`migration steps to apply by hand:`), never a destructive rewrite of +a line you've edited. There are no migrations to apply yet — the mechanism ships +ahead of the first release that needs one, so the upgrade path is already in +place. + ### One-time cleanup for sites created before these landed `--sync` detects drift it can't safely automate and prints a checklist — it diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index d8771e7..38c308e 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -4,6 +4,7 @@ require "yaml" require "rails/generators/base" require_relative "sync_report" +require_relative "migration_registry" require_relative "../../../docs_kit/version" module DocsKit @@ -44,6 +45,18 @@ class InstallGenerator < ::Rails::Generators::Base AGENTS_END = "" AGENTS_BLOCK_RE = /#{Regexp.escape(AGENTS_BEGIN)}.*#{Regexp.escape(AGENTS_END)}/m + # The config initializer — the one file every site has, so it carries the + # last-synced version stamp. See stamp_synced_version / run_migrations. + INITIALIZER = "config/initializers/docs_kit.rb" + + # The inert comment recording which docs-kit version last synced this site. + # A future `--sync` reads it to run only the ordered migrations between that + # version and the gem's current one. Absent → the site predates the stamp + # (treated as the earliest version, so every migration applies). + SYNCED_STAMP_RE = /^#\s*docs-kit synced:\s*v(\d+\.\d+\.\d+)\s*$/ + + def self.synced_stamp(version = DocsKit::VERSION) = "# docs-kit synced: v#{version}" + # The RuboCop wiring docs-kit injects. REQUIRE loads the cops; # INHERIT_GEM/INHERIT_PATH enable + scope them (see config/rubocop/docs_kit.yml). RUBOCOP_REQUIRE = "docs_kit/rubocop" @@ -308,6 +321,43 @@ def report_drift report.items.each { |item| say " • #{item}" } end + # Run the ordered release-to-release migrations between the site's + # last-synced version and the gem's current one — the payoff of the version + # stamp. `--sync` only (a full install is a fresh scaffold, not an upgrade), + # and BEFORE stamp_synced_version restamps, so it reads the OLD version. An + # un-stamped site is treated as the earliest version (every migration runs). + # Migrations are warn-only-safe: what they can't automate they hand back as + # a checklist, printed like the drift report. The registry ships EMPTY at + # 1.0.x, so today this is a no-op that establishes the upgrade path. + def run_migrations + return unless options[:sync] + + warnings = MigrationRegistry.default.migrate!(synced_version, destination_root, self) + return if warnings.empty? + + say_status :warn, "migration steps to apply by hand:", :yellow + warnings.each { |item| say " • #{item}" } + end + + # Record which docs-kit version this site is now synced at, so the NEXT + # `--sync` can run only the migrations after it. Injected as an inert + # comment at the top of the initializer (the one file every site has) — + # which create_initializer never rewrites, so stamping is its own step. + # Idempotent: updates a stale stamp in place, adds one when absent, and + # is a no-op when already current. + def stamp_synced_version + path = File.join(destination_root, INITIALIZER) + return unless File.exist?(path) + + current = self.class.synced_stamp + source = File.read(path) + updated = source.match?(SYNCED_STAMP_RE) ? source.sub(SYNCED_STAMP_RE, current) : "#{current}\n#{source}" + return if updated == source + + File.write(path, updated) + say_status :update, "#{INITIALIZER} (synced v#{DocsKit::VERSION})", :green + end + def show_post_install return show_sync_summary if options[:sync] @@ -328,6 +378,17 @@ def show_post_install private + # The docs-kit version this site was last synced at, read from the + # initializer's stamp. Un-stamped (a site created before the stamp landed, + # or a fresh skeleton) → "0.0.0", the earliest version, so every migration + # applies. Read BEFORE stamp_synced_version overwrites it. + def synced_version + path = File.join(destination_root, INITIALIZER) + return "0.0.0" unless File.exist?(path) + + File.read(path)[SYNCED_STAMP_RE, 1] || "0.0.0" + end + def show_sync_summary say_status :info, "docs-kit synced.", :green say <<~MSG diff --git a/lib/generators/docs_kit/install/migration.rb b/lib/generators/docs_kit/install/migration.rb new file mode 100644 index 0000000..c61203b --- /dev/null +++ b/lib/generators/docs_kit/install/migration.rb @@ -0,0 +1,32 @@ +# frozen_string_literal: true + +module DocsKit + module Generators + # One ordered, versioned upgrade step between two docs-kit releases. `to` is + # the version the migration belongs to (the release that introduced the + # change); the registry runs it when a site's last-synced version is BELOW + # `to`. The block receives the site's `(destination_root, generator)` so it + # can read/rewrite files via the generator's helpers, and returns the list + # of manual-cleanup warnings it could NOT safely automate. + # + # Warn-only-safe by contract (the #24 drift pattern): a migration does what + # it can idempotently — never a destructive rewrite of a hand-edited line — + # and hands back strings for whatever needs a human. A `nil` return means + # "nothing to warn about". + class Migration + attr_reader :to, :description + + def initialize(to:, description:, &block) + @to = Gem::Version.new(to.to_s) + @description = description + @block = block + end + + # Run the transform against the site. Returns the (possibly empty) list of + # manual-cleanup warnings — never nil, so callers can flat-map safely. + def call(root, generator) + Array(@block.call(root, generator)) + end + end + end +end diff --git a/lib/generators/docs_kit/install/migration_registry.rb b/lib/generators/docs_kit/install/migration_registry.rb new file mode 100644 index 0000000..b1f1309 --- /dev/null +++ b/lib/generators/docs_kit/install/migration_registry.rb @@ -0,0 +1,57 @@ +# frozen_string_literal: true + +require_relative "migration" +require_relative "../../../docs_kit/version" + +module DocsKit + module Generators + # The ordered set of release-to-release migrations `--sync` applies. Given a + # site's last-synced version, it selects the migrations the site hasn't run + # yet — those in the half-open range `(from_version, upto]`: ABOVE the site + # version (a migration AT the site version is already applied) and no newer + # than the installed gem — and runs them in ascending order, collecting the + # warn-only messages each couldn't safely automate. + # + # The `upto` ceiling (default: the installed gem version) matters because + # `--sync` restamps the site to DocsKit::VERSION afterward. A migration whose + # `to` sat ABOVE the gem version would then still exceed the new stamp and + # re-run on EVERY sync forever. It can't legitimately exist anyway (a site + # can't have "arrived" at a release it doesn't have), so it's filtered out. + # + # `.default` is the registry the generator uses. It SHIPS EMPTY at 1.0.x — + # the mechanism (stamp the synced version, detect the gap, run ordered + # transforms) is the deliverable; the first concrete `1.x → 1.y` transform is + # a one-line `Migration.new(...)` addition here once a release needs one. + class MigrationRegistry + def initialize(migrations = []) + @migrations = migrations.sort_by(&:to) + end + + # The registry the install generator runs during `--sync`. Empty today. + def self.default + @default ||= new(MIGRATIONS) + end + + # No migrations to register yet — the mechanism is the 1.0 deliverable. + # Add ordered `Migration.new(to: "1.x.0", description: "...") { ... }` + # entries here as future releases change config knobs, routes, or templates. + MIGRATIONS = [].freeze + + # The migrations a site last synced at `from_version` still needs, ascending + # by version — those in `(from_version, upto]`. `upto` defaults to the + # installed gem version so a migration targeting an unreleased version never + # applies (and never re-runs against the post-sync stamp). + def applicable(from_version, upto: DocsKit::VERSION) + from = Gem::Version.new(from_version.to_s) + ceiling = Gem::Version.new(upto.to_s) + @migrations.select { |migration| migration.to > from && migration.to <= ceiling } + end + + # Run every applicable migration in order against the site, returning the + # flattened list of manual-cleanup warnings they couldn't safely automate. + def migrate!(from_version, root, generator, upto: DocsKit::VERSION) + applicable(from_version, upto: upto).flat_map { |migration| migration.call(root, generator) } + end + end + end +end diff --git a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb index 6e07e0f..130f597 100644 --- a/lib/generators/docs_kit/install/templates/docs_kit.rb.erb +++ b/lib/generators/docs_kit/install/templates/docs_kit.rb.erb @@ -1,3 +1,4 @@ +<%= self.class.synced_stamp %> # frozen_string_literal: true # docs-kit configuration — everything that makes this site look like YOUR docs. diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 47fde59..1d87c47 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -5,6 +5,8 @@ require "tmpdir" require "rails/generators" require "generators/docs_kit/install/install_generator" +require "generators/docs_kit/install/migration" +require "generators/docs_kit/install/migration_registry" # The install generator never touches a booted Rails app — it only reads/writes # files under destination_root via Thor. So we exercise it against a throwaway @@ -430,10 +432,14 @@ def capture_stream write("config/initializers/docs_kit.rb", edited_config) end - it "preserves the site's edited config byte-for-byte on re-run" do + it "preserves the site's edited config body on re-run (only prepends the inert version stamp)" do run_generator - expect(read("config/initializers/docs_kit.rb")).to eq(edited_config) + result = read("config/initializers/docs_kit.rb") + # The edited config body is untouched — just prefixed with the synced-version + # stamp comment (an inert line the migration machinery reads on the next sync). + expect(result).to include(edited_config.rstrip) + expect(result).to eq("# docs-kit synced: v#{DocsKit::VERSION}\n#{edited_config}") end it "reports the skip and hints at the template for an upgrade diff" do @@ -1033,4 +1039,147 @@ def render_page(view) expect(output).not_to match(/Dockerfile is v|Dockerfile.*older/i) end end + + # Version-aware sync: the generator records which docs-kit version a site was + # last synced at (a `# docs-kit synced: vX.Y.Z` stamp in the initializer) so a + # future `--sync` can run the ORDERED migrations between that version and the + # gem's current version — not just diff against head. The stamp is inert (a + # comment), lives in the one file every site has, and is injectable into an + # existing initializer the generator otherwise never rewrites. + describe "version stamping (records the last-synced docs-kit version)" do + let(:stamp) { "# docs-kit synced: v#{DocsKit::VERSION}" } + + it "stamps the current gem version into the initializer on a full install" do + build_skeleton + run_generator + + expect(read("config/initializers/docs_kit.rb")).to include(stamp) + end + + it "injects the stamp into an existing (never-clobbered) initializer that lacks one" do + # A site created before this feature has an un-stamped initializer the + # generator must not rewrite. --sync injects the stamp comment without + # touching the site's config body. + build_skeleton + write("config/initializers/docs_kit.rb", <<~RUBY) + # frozen_string_literal: true + DocsKit.configure do |c| + c.brand = "My Hand-Edited Brand" + end + RUBY + + run_generator(sync: true) + + initializer = read("config/initializers/docs_kit.rb") + expect(initializer).to include(stamp) + # The site's edited config body is preserved. + expect(initializer).to include(%(c.brand = "My Hand-Edited Brand")) + end + + it "updates a stale stamp to the current version on --sync (never duplicates it)" do + build_skeleton + write("config/initializers/docs_kit.rb", <<~RUBY) + # docs-kit synced: v0.9.0 + # frozen_string_literal: true + DocsKit.configure { |c| c.brand = "X" } + RUBY + + run_generator(sync: true) + + initializer = read("config/initializers/docs_kit.rb") + expect(initializer).to include(stamp) + expect(initializer).not_to include("v0.9.0") + expect(initializer.scan("docs-kit synced:").size).to eq(1) + end + + it "is idempotent — a second run leaves exactly one current stamp" do + build_skeleton + run_generator + run_generator(sync: true) + + initializer = read("config/initializers/docs_kit.rb") + expect(initializer.scan("docs-kit synced:").size).to eq(1) + expect(initializer).to include(stamp) + end + end + + # The payoff of the stamp: --sync computes the gap between the site's + # last-synced version and the gem's version and runs the ordered migrations + # in between, printing warn-only messages for anything a migration can't + # safely automate (the #24 drift pattern). The default registry ships EMPTY at + # 1.0.x, so these assert the WIRING (the gap is read, the registry is invoked, + # warnings surface) against an injected registry rather than a real transform. + describe "version-aware migrations (--sync runs ordered transforms across the gap)" do + # A registry whose one migration records that it ran (by writing a marker + # file into the site) and emits a warning — so we can assert the generator + # read the stamp, invoked the registry, and printed the warning. `to` + # defaults to the installed gem version (a migration introduced in THIS + # release): the realistic case, and within the registry's `upto` ceiling so + # it's applicable to a site stamped below it. A `to` above DocsKit::VERSION + # would be filtered as unreleased. + def stub_default_registry(to: DocsKit::VERSION, warnings: ["do the manual thing the migration can't automate"]) + migration = DocsKit::Generators::Migration.new(to: to, description: "a migration") do |root, _gen| + File.write(File.join(root, ".migration-ran"), "yes") + warnings + end + registry = DocsKit::Generators::MigrationRegistry.new([migration]) + allow(DocsKit::Generators::MigrationRegistry).to receive(:default).and_return(registry) + end + + it "runs applicable migrations for a stamped site and prints their warnings" do + build_skeleton + run_generator # full install → stamps the current version + # Roll the stamp back so the stubbed migration (at the gem version) is applicable. + write("config/initializers/docs_kit.rb", + "# docs-kit synced: v0.9.0\n#{read('config/initializers/docs_kit.rb')}") + stub_default_registry + + generator = described_class.new([], { sync: true }, destination_root: destination) + output = capture_stream { generator.invoke_all } + + expect(exist?(".migration-ran")).to be(true) + expect(output).to include("do the manual thing the migration can't automate") + end + + it "runs no migrations when the site is already at the current version" do + build_skeleton + run_generator # stamps current version + # A migration AT the current version has already been applied at that sync. + stub_default_registry(to: DocsKit::VERSION, warnings: []) + + generator = described_class.new([], { sync: true }, destination_root: destination) + capture_stream { generator.invoke_all } + + expect(exist?(".migration-ran")).to be(false) + end + + it "treats a pre-feature (un-stamped) site as earliest (runs every migration)" do + build_skeleton + # A site created before the stamp landed: it HAS an initializer, but with + # no synced-version stamp. create_initializer never clobbers it, so it + # survives into run_migrations, which reads it as 0.0.0 (earliest) — every + # migration up to the gem version applies. + write("config/initializers/docs_kit.rb", <<~RUBY) + # frozen_string_literal: true + DocsKit.configure { |c| c.brand = "Legacy Site" } + RUBY + stub_default_registry + + generator = described_class.new([], { sync: true }, destination_root: destination) + capture_stream { generator.invoke_all } + + expect(exist?(".migration-ran")).to be(true) + end + + it "does not run migrations on a full (non-sync) install" do + build_skeleton + stub_default_registry + + generator = described_class.new([], {}, destination_root: destination) + capture_stream { generator.invoke_all } + + # A full install is a fresh scaffold, not an upgrade — nothing to migrate. + expect(exist?(".migration-ran")).to be(false) + end + end end diff --git a/spec/generators/migration_registry_spec.rb b/spec/generators/migration_registry_spec.rb new file mode 100644 index 0000000..e371dd1 --- /dev/null +++ b/spec/generators/migration_registry_spec.rb @@ -0,0 +1,134 @@ +# frozen_string_literal: true + +require "generators/docs_kit/install/migration" +require "generators/docs_kit/install/migration_registry" + +# The version-aware migration machinery is pure Ruby — no Rails boot, no +# destination root. A Migration is an ordered, versioned transform (its `to` +# version is the release that introduced it); the registry selects the ones a +# site hasn't applied yet (from the site's last-synced version, exclusive) and +# runs them in order, collecting the warnings each can't safely automate. +# +# The registry SHIPS EMPTY at 1.0.x — there is no cross-version transform to +# write yet. These specs exercise the mechanism against an injected list so the +# first real `1.x → 1.y` migration is a one-line addition, fully covered. +RSpec.describe DocsKit::Generators::MigrationRegistry do + # A migration that records that it ran (into `log`) and returns the warnings + # it was seeded with. `call` takes (root, generator); here both are nil — the + # registry doesn't care what a migration does, only its `to` + return value. + def migration(to:, log:, warnings: []) + DocsKit::Generators::Migration.new( + to: to, + description: "to #{to}" + ) do |_root, _generator| + log << Gem::Version.new(to) + warnings + end + end + + # Fixture versions stay BELOW the installed gem version (DocsKit::VERSION) so + # the default `upto` ceiling never filters them — these specs isolate the + # from-version gap logic. The ceiling itself is covered separately below. + describe ".applicable" do + subject(:registry) { described_class.new(migrations) } + + let(:log) { [] } + let(:migrations) do + [ + migration(to: "0.1.0", log: log), + migration(to: "0.3.0", log: log), + migration(to: "0.2.0", log: log) + ] + end + + it "returns migrations newer than the site's version, in ascending order" do + applicable = registry.applicable("0.1.0") + + expect(applicable.map(&:to)).to eq([Gem::Version.new("0.2.0"), Gem::Version.new("0.3.0")]) + end + + it "treats the site version as exclusive (a migration AT the site version is already applied)" do + # A site synced at 0.2.0 has already run the 0.2.0 migration. + expect(registry.applicable("0.2.0").map(&:to)).to eq([Gem::Version.new("0.3.0")]) + end + + it "returns every migration for an unknown/earliest site (0.0.0)" do + expect(registry.applicable("0.0.0").map(&:to)) + .to eq([Gem::Version.new("0.1.0"), Gem::Version.new("0.2.0"), Gem::Version.new("0.3.0")]) + end + + it "returns none when the site is at or ahead of the newest migration" do + expect(registry.applicable("0.3.0")).to be_empty + expect(registry.applicable("2.0.0")).to be_empty + end + + # A migration can't legitimately target a version newer than the installed + # gem (you can't have "arrived" at a release you don't have). Capping at the + # gem version means that after --sync restamps the site to DocsKit::VERSION, + # nothing re-runs — without this bound a mis-registered `to:` above VERSION + # would fire on EVERY sync forever. Surfaced by the end-to-end drive. + it "never returns a migration newer than the upto ceiling" do + # 0.3.0 exists in the list; cap the effective ceiling at 0.2.0. + expect(registry.applicable("0.1.0", upto: "0.2.0").map(&:to)).to eq([Gem::Version.new("0.2.0")]) + end + + it "defaults the upto ceiling to the current gem version (filters unreleased migrations)" do + # A migration targeting a version ABOVE the installed gem can't apply — the + # site can't have that release. With the default ceiling it's filtered out. + future = Gem::Version.new(DocsKit::VERSION).bump.to_s # e.g. 1.1 for a 1.0.x gem + ahead = described_class.new([migration(to: future, log: log)]) + + expect(ahead.applicable("0.0.0")).to be_empty + end + end + + describe ".migrate!" do + subject(:registry) { described_class.new(migrations) } + + let(:log) { [] } + let(:migrations) do + [ + migration(to: "0.1.0", log: log, warnings: ["rename c.foo → c.bar"]), + migration(to: "0.2.0", log: log, warnings: []), + migration(to: "0.3.0", log: log, warnings: ["delete old_route"]) + ] + end + + it "runs only the applicable migrations, in ascending order" do + registry.migrate!("0.1.0", nil, nil) + + expect(log).to eq([Gem::Version.new("0.2.0"), Gem::Version.new("0.3.0")]) + end + + it "collects the warnings each migration couldn't safely automate" do + warnings = registry.migrate!("0.1.0", nil, nil) + + expect(warnings).to eq(["delete old_route"]) + end + + it "collects warnings across every applied migration (from an earliest site)" do + warnings = registry.migrate!("0.0.0", nil, nil) + + expect(warnings).to contain_exactly("rename c.foo → c.bar", "delete old_route") + end + + it "runs nothing and warns nothing when the site is current" do + expect(registry.migrate!("0.3.0", nil, nil)).to eq([]) + expect(log).to be_empty + end + end + + # The registry the generator actually uses. It ships EMPTY at 1.0.x — the + # mechanism is the deliverable, not any concrete transform yet. This guards + # that .default exists and is a real registry (so wiring can call it), while + # documenting that no migrations are registered at this version. + describe ".default" do + it "is a MigrationRegistry" do + expect(described_class.default).to be_a(described_class) + end + + it "ships no migrations yet (the mechanism is the 1.0 deliverable)" do + expect(described_class.default.applicable("0.0.0")).to be_empty + end + end +end diff --git a/spec/generators/migration_spec.rb b/spec/generators/migration_spec.rb new file mode 100644 index 0000000..38e4b9e --- /dev/null +++ b/spec/generators/migration_spec.rb @@ -0,0 +1,48 @@ +# frozen_string_literal: true + +require "generators/docs_kit/install/migration" + +# A Migration is one ordered, versioned transform: the release it belongs to +# (`to`), a human description, and a block run with (root, generator) that +# returns the list of manual-cleanup warnings it couldn't safely automate. +# Warn-only-safe by contract — the block does what it can idempotently and hands +# back strings for the rest (the #24 drift-report pattern). +RSpec.describe DocsKit::Generators::Migration do + subject(:migration) do + described_class.new(to: "1.2.0", description: "rename c.foo → c.bar") do |root, generator| + ran << [root, generator] + ["couldn't touch a hand-edited line"] + end + end + + let(:ran) { [] } + + it "coerces `to` into a Gem::Version (so the registry can order/compare)" do + expect(migration.to).to eq(Gem::Version.new("1.2.0")) + end + + it "exposes its description" do + expect(migration.description).to eq("rename c.foo → c.bar") + end + + it "runs its block with (root, generator) and returns the block's warnings" do + warnings = migration.call("/site", :the_generator) + + expect(ran).to eq([["/site", :the_generator]]) + expect(warnings).to eq(["couldn't touch a hand-edited line"]) + end + + it "returns an empty array when the block returns nil (nothing to warn about)" do + silent = described_class.new(to: "1.1.0", description: "no-op") { |_root, _gen| nil } + + expect(silent.call("/site", nil)).to eq([]) + end + + it "orders naturally by version" do + a = described_class.new(to: "1.1.0", description: "a") { [] } + b = described_class.new(to: "1.10.0", description: "b") { [] } + + # String sort would put "1.10.0" before "1.2.0"; Gem::Version must not. + expect([b, a].sort_by(&:to).map(&:to)).to eq([Gem::Version.new("1.1.0"), Gem::Version.new("1.10.0")]) + end +end