feat(generator): version-aware migrations between docs-kit releases in --sync

Merged
MH
4mo ago
+509-2

Summary

Closes #41. docs_kit:install --sync was deliberately version-agnostic (the right v1 call): it reconciled a site against the current gem template with no notion of which docs-kit version the site was last synced from. Now that the gem is post-1.0, this adds the mechanism to apply the ordered migration steps between a site's last-synced version and the current one.

What changed

PieceWhat it does
Version stampEvery 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.
MigrationAn ordered, versioned transform: to (the release it belongs to), a description, and a block run with (root, generator) that returns the warn-only messages it couldn't safely automate (nil → []).
MigrationRegistrySelects the migrations in the half-open range (last_synced, gem_version], runs them ascending, collects warnings. Ships empty at 1.0.x — the mechanism is the deliverable.
--sync wiringReads the stamp before restamping, runs the applicable migrations, prints a migration steps to apply by hand: checklist for anything warn-only, then restamps. Full (non-sync) installs don't migrate — a fresh scaffold isn't an upgrade.

The first concrete 1.x → 1.y migration is now a one-line Migration.new(to: "1.x.0", description: "...") { ... } addition to MigrationRegistry::MIGRATIONS.

Key invariant (surfaced by an end-to-end drive, not the mocked tests)

applicable caps at the installed gem version (upto:, default DocsKit::VERSION). A migration targeting a version above the gem can't legitimately apply — a site can't have "arrived" at a release it doesn't have — and, without the cap, would re-fire on every sync after the site restamps to the gem version. The upto bound is what makes --sync idempotent. This bug only appeared when I drove the real generator on disk; it's now covered by both a unit spec and an end-to-end assertion.

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 new-site template runs docs_kit:install, so no separate wiring is needed.
  • Zeitwerk is unaffected: lib/generators/** is outside the loader's push-dirs; the new files are loaded by Rails' generator system / require_relative.

Test Coverage

  • spec/generators/migration_spec.rb — the Migration value object: version coercion, block invocation with (root, generator), nil → [] warnings, natural version ordering (1.10.0 after 1.1.0).
  • spec/generators/migration_registry_spec.rb — applicable gap logic (exclusive from, ascending order, unknown/ahead sites, the upto ceiling filtering unreleased migrations) and migrate! ordering + warning collection; .default ships empty.
  • spec/generators/install_generator_spec.rb — stamping (on install, injected into an existing initializer, updates a stale stamp, idempotent) and the --sync migration wiring (runs for a stamped site and a pre-feature un-stamped site, prints warnings, no-op at current version, not on a full install).

Verification

  • bundle exec rspec — 780 examples, 0 failures, 94.82% line coverage
  • bundle exec rubocop — no offenses
  • End-to-end drive of the real generator against a throwaway skeleton: stamp on install → roll back → gap-detected migration runs on sync → restamp → idempotent second sync

Test plan for a reviewer

bundle exec rspec spec/generators/
bundle exec rubocop lib/generators/docs_kit/install/

https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX