From 782c7cb31620117a164fd0a0b03f59a5bcedd819 Mon Sep 17 00:00:00 2001 From: mhenrixon Date: Fri, 3 Jul 2026 14:41:44 +0200 Subject: [PATCH] fix(generator): idempotent install + a consumer re-sync guide MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Makes `rails g docs_kit:install` safe to re-run on a years-old site, so re-running it becomes the sanctioned upgrade path (rather than sites fossilizing on the generator output of the day they were created). ## Summary - create_initializer skips (never clobbers) a site's edited config/initializers/docs_kit.rb; prints a diff hint at the gem template. - add_routes is now quote/syntax tolerant: a route the site already drew (single vs double quotes, `to:` vs `=>`) is skipped, not duplicated. - register_stimulus_controller skips when the docs_kit path is already wired via EITHER loader (eager OR lazy) — no more double-registration. - New `--sync` flag: runs only the additive/wiring steps (routes, initializer hint, importmap/Stimulus, AGENTS.md, .rubocop.yml), never re-scaffolds site-owned content, and prints a conservative drift checklist (hand-written render_page, dead IconHelper) — warns, never auto-deletes. Extracted to a SyncReport helper class. - README "Keeping a site in sync" section + one-time cleanup table; CHANGELOG Added/Fixed entries. ## Test Coverage - route idempotency against a hand-written, differently-styled routes.rb (single quotes, `to:`, no `.:format`) — no duplicate root/docs#show/search - create_initializer preserves edited config byte-for-byte on re-run - --sync scaffolds no site content (registry/pages/CSS) and is idempotent - --sync drift detection flags render_page + IconHelper, never deletes them - stimulus: no double-register when the site already lazy-loads docs_kit ## Verification - [x] bundle exec rubocop passes (103 files) - [x] bundle exec rspec passes (505 examples, 96.57% line coverage) - [x] Live --sync vs the gem's own docs/ app: no-op, ZERO drift warnings - [x] Live --sync vs phlex-reactive/docs: flags render_page + IconHelper, skips its single-quote routes, no double stimulus registration Closes #24 Claude-Session: https://claude.ai/code/session_01FPQb6z3YwcKRMbvoJhdxnX --- CHANGELOG.md | 15 + README.md | 37 +++ .../docs_kit/install/install_generator.rb | 123 +++++++- .../docs_kit/install/sync_report.rb | 64 +++++ spec/generators/install_generator_spec.rb | 269 +++++++++++++++++- 5 files changed, 482 insertions(+), 26 deletions(-) create mode 100644 lib/generators/docs_kit/install/sync_report.rb diff --git a/CHANGELOG.md b/CHANGELOG.md index 4af7cff..146d622 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -21,3 +21,18 @@ `DocsKit/EscapedInterpolationInHeredoc` (steer `\#{...}` escapes in a double-quoted heredoc to a single-quoted delimiter). RuboCop stays a development-time dependency of the host — never a runtime dependency. +- `rails g docs_kit:install --sync`: the sanctioned upgrade path for an existing + site. Runs only the additive/wiring steps (routes, initializer hint, + importmap/Stimulus registration, AGENTS.md, `.rubocop.yml`) — never + re-scaffolds site-owned content (the `Doc` registry, pages, the themed CSS + build) — and prints a conservative drift checklist (a hand-written + `render_page`, a dead `IconHelper`) it warns about but never auto-deletes. See + the README "Keeping a site in sync" section. + +### Fixed + +- `rails g docs_kit:install` is now fully idempotent, making re-running it the + safe upgrade path: `create_initializer` no longer clobbers a site's edited + `config/initializers/docs_kit.rb` (it skips + hints at the template for a + diff), and `add_routes` no longer duplicates a route the site already drew when + it was written in a different style (single vs double quotes, `to:` vs `=>`). diff --git a/README.md b/README.md index 6ebfd58..b3d8dc4 100644 --- a/README.md +++ b/README.md @@ -53,6 +53,43 @@ gem "rouge" # gem "phlex-reactive" ``` +## Keeping a site in sync + +The install generator is the **upgrade path**, not a one-shot. Every step is +idempotent — safe to re-run on a years-old site — so bumping the gem and +re-running it pulls in whatever wiring newer docs-kit versions added (routes, +initializer hints, the AGENTS.md authoring block, the RuboCop cops) without +touching a byte you've edited. Your config initializer is skipped (never +clobbered); routes you already drew are skipped even if you wrote them in your +own style (single quotes, `to:` vs `=>`). + +To upgrade an existing site: + +```bash +bundle update docs-kit +bin/rails g docs_kit:install --sync # wiring only — scaffolds no site content +# → act on any "manual cleanup needed" warnings it prints (see below) +bun run build:css # pick up any newly emitted classes +bundle exec rspec # confirm the site still boots + renders +``` + +`--sync` runs only the additive/wiring steps and **never** re-scaffolds +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. + +### One-time cleanup for sites created before these landed + +`--sync` detects drift it can't safely automate and prints a checklist — it +**warns, never deletes**. The common items on sites scaffolded by older +generators: + +| Drift | Why it's dead | Fix | +|-------|---------------|-----| +| `ApplicationController#render_page` defined by hand | `DocsKit::Controller#render_page` is included (the generator injects `include DocsKit::Controller`) | Delete the method — keep the `include`. | +| `app/helpers/icon_helper.rb` | docs-kit renders icons via rails_icons (`DocsUI::Icon`) | Delete the file. | +| Hand-pinned docs-kit lines in `config/importmap.rb` | the engine auto-pins the `docs-nav` controller and its assets | Delete the manual `pin`/`pin_all_from` lines for docs-kit. | + ## Configure (per site) ```ruby diff --git a/lib/generators/docs_kit/install/install_generator.rb b/lib/generators/docs_kit/install/install_generator.rb index 98a6b42..716d59c 100644 --- a/lib/generators/docs_kit/install/install_generator.rb +++ b/lib/generators/docs_kit/install/install_generator.rb @@ -3,6 +3,7 @@ require "erb" require "yaml" require "rails/generators/base" +require_relative "sync_report" module DocsKit module Generators @@ -14,10 +15,22 @@ module Generators # a fresh `rails new` app (the new-site template does exactly this), or on top # of an existing app to add a docs section. # - # Everything is idempotent — re-running skips files that already exist. + # Fully idempotent — safe on a fresh app AND a years-old site, which makes + # re-running it the sanctioned upgrade path. Every step guards a re-run: the + # config initializer is skipped (never clobbered); routes are skipped even + # when the site wrote them in its own style (single quotes, `to:` vs `=>`); + # file creations skip what already exists. `--sync` runs ONLY the additive + # wiring (routes, initializer hint, importmap/stimulus, AGENTS.md, .rubocop) + # and prints a checklist of manual drift it can't safely automate. class InstallGenerator < ::Rails::Generators::Base source_root File.expand_path("templates", __dir__) + # `--sync`: the upgrade path for an existing site. Runs the wiring steps + # (idempotent) and skips scaffolding site-owned content (the doc registry, + # sample pages, the CSS build) — those already exist and are the site's. + class_option :sync, type: :boolean, default: false, + desc: "Upgrade an existing site: re-run wiring only, report drift, scaffold no content" + # eagerLoadControllersFrom (NOT lazy) — the default controllers/index.js only # imports eagerLoadControllersFrom; injecting a lazyLoadControllersFrom call # without its import throws a ReferenceError that aborts the whole module, so @@ -70,8 +83,18 @@ def create_rails_icons_initializer template "rails_icons.rb.erb", icons end + # The site's config (brand, themes, nav) lives here and is heavily edited, + # so a re-run must NEVER clobber it. Skip when present and point an upgrader + # at the current template for a manual diff. (create_rails_icons_initializer + # and create_phlex_initializer already follow this skip-if-exists pattern.) def create_initializer - template "docs_kit.rb.erb", "config/initializers/docs_kit.rb" + initializer = "config/initializers/docs_kit.rb" + if File.exist?(File.join(destination_root, initializer)) + template_path = File.join(self.class.source_root, "docs_kit.rb.erb") + return say_status(:skip, "#{initializer} exists — compare with #{template_path} if upgrading", :blue) + end + + template "docs_kit.rb.erb", initializer end def include_controller_helper @@ -86,7 +109,12 @@ def include_controller_helper inject_into_class controller, "ApplicationController", " include DocsKit::Controller\n" end + # Site-owned content — the doc registry, its controllers, the sample guide + # page, the landing. A `--sync` upgrade never scaffolds these: they exist + # and are the site's to edit. def create_registry_and_pages + return say_status(:skip, "site content (--sync: registry/pages are yours)", :blue) if options[:sync] + template "doc.rb.erb", "app/models/doc.rb" template "docs_controller.rb.erb", "app/controllers/docs_controller.rb" template "landings_controller.rb.erb", "app/controllers/landings_controller.rb" @@ -99,20 +127,19 @@ def add_routes # docs#show with request.format.md?, so DocsKit::Controller#render_page # returns the page's GFM. No `defaults: { format: "html" }` — that would # pin html and defeat the .md route. - route %(get "docs/:doc(.:format)" => "docs#show", as: :doc) + route_once %(get "docs/:doc(.:format)" => "docs#show", as: :doc) # Docs search, served from the registry by the gem's DocsKit::SearchController # (matches the default c.search_path). Thor's `route` PREPENDS, so this call # — after the docs route above — lands ABOVE `docs/:doc` in the file, where # it must be: otherwise `docs/:doc` would swallow /docs/search as :doc. - route %(get "/docs/search" => "docs_kit/search#index", as: :docs_search) - route %(root "landings#show") + route_once %(get "/docs/search" => "docs_kit/search#index", as: :docs_search) + route_once %(root "landings#show") # AI-readable docs (llmstxt.org), served from the registry by the gem's # DocsKit::LlmsController — zero authoring. /llms.txt is the index; - # /llms-full.txt concatenates every page's Markdown twin. Thor's `route` - # skips a line already present, so re-running the generator is idempotent. - route %(get "/llms.txt" => "docs_kit/llms#index", as: :llms) - route %(get "/llms-full.txt" => "docs_kit/llms#full", as: :llms_full) + # /llms-full.txt concatenates every page's Markdown twin. + route_once %(get "/llms.txt" => "docs_kit/llms#index", as: :llms) + route_once %(get "/llms-full.txt" => "docs_kit/llms#full", as: :llms_full) add_mcp_route end @@ -128,7 +155,12 @@ def add_mcp_route route %(# Add your docs to an agent over MCP (needs `gem "mcp"`):) end + # The CSS build — its stylesheet carries the site's theme @plugin block, so + # a `--sync` upgrade leaves it alone (an existing site has already built + + # customized it). def create_css_build + return say_status(:skip, "CSS build (--sync: application.tailwind.css is yours)", :blue) if options[:sync] + template "application.tailwind.css.erb", "app/assets/stylesheets/application.tailwind.css" template "build-css", "bin/build-css" chmod "bin/build-css", 0o755 @@ -178,15 +210,32 @@ def wire_rubocop_cops def register_stimulus_controller index = stimulus_index_path return say_status(:skip, "no controllers/index.js — add: #{REGISTER_LINE}", :yellow) unless index - return say_status(:identical, relative(index), :blue) if File.read(index).include?(REGISTER_LINE) + # Skip if the docs_kit path is already registered via EITHER loader — a + # site that wired it lazily is valid (the engine auto-pins it); injecting + # our eager line would duplicate the registration. Quote-style tolerant. + return say_status(:identical, relative(index), :blue) if stimulus_registered?(index) inject_into_file index, after: /eagerLoadControllersFrom\([^\n]*\n/ do "#{REGISTER_LINE}\n" end - append_to_file(index, "\n#{REGISTER_LINE}\n") unless File.read(index).include?(REGISTER_LINE) + append_to_file(index, "\n#{REGISTER_LINE}\n") unless stimulus_registered?(index) + end + + # Detect + print manual drift the generator can't safely automate (a + # hand-written render_page, a dead IconHelper). String-level and + # conservative — it warns, never deletes, and never fails the run. Runs on + # every invocation; it's the headline deliverable of a `--sync` upgrade. + def report_drift + report = SyncReport.new(destination_root) + return if report.clean? + + say_status :warn, "manual cleanup needed (docs-kit now provides these):", :yellow + report.items.each { |item| say " • #{item}" } end def show_post_install + return show_sync_summary if options[:sync] + say_status :info, "docs-kit installed.", :green say <<~MSG @@ -204,6 +253,17 @@ def show_post_install private + def show_sync_summary + say_status :info, "docs-kit synced.", :green + say <<~MSG + + Next: + 1. Act on any drift warnings above (delete the flagged files). + 2. bun run build:css # pick up any new emitted classes + 3. bundle exec rspec # confirm the site still boots + renders + MSG + end + # Create AGENTS.md whole when absent; otherwise replace only the delimited # docs-kit block (or append it if the file predates docs-kit), leaving the # user's own content intact. @@ -283,6 +343,40 @@ def render_template(name) ERB.new(source, trim_mode: "-").result(binding) end + # Draw a route unless the site already has one for the same endpoint — + # tolerant of the site's own style (single vs double quotes, `to:` vs `=>`, + # extra whitespace). Thor's `route` only skips a BYTE-IDENTICAL line, so a + # years-old hand-written routes.rb would get a duplicate; this guard makes + # re-running a genuine no-op. We never rewrite the site's line — drift is + # warned, not auto-edited. + def route_once(routing_code) + return route(routing_code) unless route_present?(routing_code) + + say_status(:identical, "route #{route_endpoint(routing_code)} (already drawn)", :blue) + end + + # True if config/routes.rb already draws this route's endpoint. Matches the + # `controller#action` string in any quote style, or — for `root` — the bare + # `root` keyword (a file has at most one). + def route_present?(routing_code) + path = File.join(destination_root, "config/routes.rb") + return false unless File.exist?(path) + + endpoint = route_endpoint(routing_code) + routes = File.read(path) + return routes.match?(/^\s*root\b/) if endpoint == :root + + routes.match?(/["']#{Regexp.escape(endpoint)}["']/) + end + + # The endpoint a route targets: `:root` for a root route, else its + # `controller#action` string (e.g. "docs#show", "docs_kit/search#index"). + def route_endpoint(routing_code) + return :root if routing_code.match?(/\Aroot\b/) + + routing_code[%r{["']([\w/]+#\w+)["']}, 1] + end + def add_package_json_scripts pkg = File.join(destination_root, "package.json") return create_file("package.json", package_json_stub) unless File.exist?(pkg) @@ -322,6 +416,13 @@ def stimulus_index_path .map { |rel| File.join(destination_root, rel) }.find { |p| File.exist?(p) } end + # True if the index already registers the docs_kit controllers path — via + # eager OR lazy loading, any quote style. A site that wired it lazily is + # valid; we must not inject a second (eager) registration on top. + def stimulus_registered?(index) + File.read(index).match?(%r{(?:eager|lazy)LoadControllersFrom\(\s*["']docs_kit/controllers["']}) + end + def relative(path) = path.sub("#{destination_root}/", "") # The brand shown in the shell — the app's name, humanized (e.g. "my_gem_docs" diff --git a/lib/generators/docs_kit/install/sync_report.rb b/lib/generators/docs_kit/install/sync_report.rb new file mode 100644 index 0000000..3d7123a --- /dev/null +++ b/lib/generators/docs_kit/install/sync_report.rb @@ -0,0 +1,64 @@ +# frozen_string_literal: true + +module DocsKit + module Generators + # Detects manual drift in an existing docs site that the install generator + # can't safely automate away — things docs-kit now provides, so the site's + # own copy is dead weight. String-level and CONSERVATIVE by design: it reads + # a few known files, reports what it finds, and never touches a byte. The + # generator prints these as a checklist during a `--sync` upgrade; the site + # owner deletes the flagged code by hand. + # + # Two drift items, both from the consumer audits: + # - ApplicationController hand-defines `render_page` — DocsKit::Controller + # (included by the generator for months) already provides it. + # - a dead IconHelper copy — the gem renders icons via rails_icons. + class SyncReport + APPLICATION_CONTROLLER = "app/controllers/application_controller.rb" + ICON_HELPER = "app/helpers/icon_helper.rb" + + def initialize(destination_root) + @root = destination_root + end + + # The drift messages, in the order a site should act on them. Empty when + # the site is clean. + def items + [render_page_drift, icon_helper_drift].compact + end + + def clean? + items.empty? + end + + private + + # ApplicationController defines its own `render_page` — DocsKit::Controller + # already provides it, so the hand-rolled method shadows the gem's and + # fossilizes whatever `layout:`/render call the site copied years ago. + def render_page_drift + source = read(APPLICATION_CONTROLLER) + return unless source&.match?(/def\s+render_page\b/) + + "#{APPLICATION_CONTROLLER} defines its own render_page — delete it; " \ + "DocsKit::Controller#render_page is included." + end + + # A leftover IconHelper — docs-kit renders icons through rails_icons + # (DocsUI::Icon), so a hand-written helper is dead code. + def icon_helper_drift + return unless exist?(ICON_HELPER) + + "#{ICON_HELPER} (IconHelper) is dead — docs-kit renders icons via " \ + "rails_icons (DocsUI::Icon); delete it." + end + + def read(rel) + path = File.join(@root, rel) + File.exist?(path) ? File.read(path) : nil + end + + def exist?(rel) = File.exist?(File.join(@root, rel)) + end + end +end diff --git a/spec/generators/install_generator_spec.rb b/spec/generators/install_generator_spec.rb index 66a1ba5..0c199d2 100644 --- a/spec/generators/install_generator_spec.rb +++ b/spec/generators/install_generator_spec.rb @@ -1,6 +1,7 @@ # frozen_string_literal: true require "fileutils" +require "stringio" require "tmpdir" require "rails/generators" require "generators/docs_kit/install/install_generator" @@ -11,11 +12,12 @@ # controllers/index.js, assets.rb) built per-example, run the generator, and assert # the produced file manifest + key contents. # -# These specs assert CURRENT behavior. Most steps guard against re-runs (Thor's -# `route`/`inject_into_file` skip a line that's already present; several methods -# `say_status(:skip)` when a target exists), so the generator is largely -# idempotent; `create_initializer` is the exception (it re-renders the template -# on every run). If a later change tightens idempotency, update these assertions. +# The generator is fully idempotent — safe on a fresh app AND a years-old site. +# Every step guards against a re-run: `create_initializer` skips a site's edited +# config; `add_routes` skips a route the site already has even when it's written +# with different quotes / `to:` vs `=>`; several methods `say_status(:skip)` when +# a target exists. A `--sync` run does ONLY the additive/wiring steps and prints +# a drift checklist for manual cleanup it can't safely automate. RSpec.describe DocsKit::Generators::InstallGenerator do # A named destination dir so app_brand humanizes deterministically # ("my_app_docs" → "My app docs"). Rails isn't booted, so app_brand falls back @@ -58,20 +60,34 @@ def write(rel, content) def read(rel) = File.read(File.join(destination, rel)) def exist?(rel) = File.exist?(File.join(destination, rel)) - # Run the generator quietly against the skeleton. - def run_generator - generator = described_class.new([], {}, destination_root: destination) - silence_stream { generator.invoke_all } + # Run the generator quietly against the skeleton. Pass generator options + # (e.g. `sync: true`) through to the Thor invocation. + def run_generator(**opts) + generator = described_class.new([], opts, destination_root: destination) + capture_stream { generator.invoke_all } end - # Thor writes progress to $stdout; keep the suite output clean. - def silence_stream - original = $stdout - $stdout = File.open(File::NULL, "w") + # Run the generator and RETURN its $stdout (Thor progress + any drift + # checklist), for asserting on printed drift warnings. + def capture_generator(**opts) + generator = described_class.new([], opts, destination_root: destination) + capture_stream { generator.invoke_all } + end + + # Thor writes progress to $stdout; capture it so the suite stays quiet AND + # specs can assert on what was printed. $stdin is closed so an unexpected + # collision prompt fails fast (EOF) rather than hanging the suite waiting on + # input — an idempotent generator never prompts, so this never fires in GREEN. + def capture_stream + original_out = $stdout + original_in = $stdin + $stdout = StringIO.new + $stdin = StringIO.new("") yield + $stdout.string ensure - $stdout.close - $stdout = original + $stdout = original_out + $stdin = original_in end before { FileUtils.rm_rf(destination) } @@ -130,6 +146,42 @@ def silence_stream end end + # The initializer is the ONE file a site is expected to edit heavily (brand, + # themes, nav). Re-running the generator (the sanctioned upgrade path) must + # NEVER clobber it — a re-run skips it and points the upgrader at the current + # template for a manual diff. + describe "config/initializers/docs_kit.rb is never clobbered on re-run" do + let(:edited_config) do + <<~RUBY + # frozen_string_literal: true + DocsKit.configure do |c| + c.brand = "My Hand-Edited Brand" + c.themes = %w[my-custom-theme] + end + RUBY + end + + before do + build_skeleton + run_generator + # Simulate a site heavily editing its config after the first install. + write("config/initializers/docs_kit.rb", edited_config) + end + + it "preserves the site's edited config byte-for-byte on re-run" do + run_generator + + expect(read("config/initializers/docs_kit.rb")).to eq(edited_config) + end + + it "reports the skip and hints at the template for an upgrade diff" do + output = capture_generator + + expect(output).to include("docs_kit.rb") + expect(output).to match(/skip|exist/i) + end + end + describe "route injection (add_routes)" do before do build_skeleton @@ -193,6 +245,46 @@ def silence_stream end end + # A years-old site wrote its routes by hand, in its OWN style (single quotes, + # `to:` instead of `=>`, no `.:format`). Thor's `route` only skips a + # byte-identical line, so a naive re-run would DUPLICATE these. The guard + # matches on the route's controller#action (quote/arrow/whitespace tolerant), + # so re-running is a genuine no-op. + describe "route idempotency against a hand-written (differently-styled) routes.rb" do + let(:handwritten_routes) do + <<~ROUTES + Rails.application.routes.draw do + root 'landings#show' + get 'docs/:doc' => 'docs#show', as: :doc + get '/docs/search', to: 'docs_kit/search#index', as: :docs_search + end + ROUTES + end + + before do + build_skeleton + write("config/routes.rb", handwritten_routes) + run_generator + end + + it "does not add a second root route" do + expect(read("config/routes.rb").scan(/root\b/).size).to eq(1) + end + + it "does not add a second docs#show route" do + expect(read("config/routes.rb").scan(/["']docs#show["']/).size).to eq(1) + end + + it "does not add a second docs_kit/search#index route" do + expect(read("config/routes.rb").scan(%r{docs_kit/search#index}).size).to eq(1) + end + + it "leaves the site's hand-written route syntax untouched" do + # We warn about drift, we never rewrite a route the site already drew. + expect(read("config/routes.rb")).to include(%(get 'docs/:doc' => 'docs#show', as: :doc)) + end + end + describe "controller injection (include_controller_helper)" do it "injects include DocsKit::Controller into ApplicationController" do build_skeleton @@ -252,6 +344,25 @@ def silence_stream expect(exist?("app/javascript/controllers/index.js")).to be(false) end + + it "does not double-register when the site already lazy-loads the docs_kit path" do + # A years-old site wired the docs-nav controller with lazyLoadControllersFrom + # (valid — the engine auto-pins it). Re-running must NOT add a second, eager + # registration on top. + build_skeleton(stimulus_index: false) + write("app/javascript/controllers/index.js", <<~JS) + import { application } from "controllers/application" + import { eagerLoadControllersFrom, lazyLoadControllersFrom } from "@hotwired/stimulus-loading" + eagerLoadControllersFrom("controllers", application) + lazyLoadControllersFrom("docs_kit/controllers", application) + JS + + run_generator + + index = read("app/javascript/controllers/index.js") + expect(index.scan("docs_kit/controllers").size).to eq(1) + expect(index).to include(%(lazyLoadControllersFrom("docs_kit/controllers", application))) + end end describe "bin/build-css" do @@ -465,4 +576,132 @@ def rubocop_config end end end + + # `--sync` is the documented upgrade path for an existing site: it runs ONLY + # the additive/wiring steps (routes, initializer hint, importmap/stimulus, + # AGENTS.md, .rubocop.yml) and prints a checklist of manual drift it detected. + # It never scaffolds site content (the doc registry, pages, the CSS build) — + # those already exist and are site-owned — and it never overwrites site files, + # so a re-run causes ZERO Thor conflict prompts. + describe "--sync mode (the upgrade path for an existing site)" do + context "when the site already has the chrome files" do + before do + build_skeleton + run_generator # first, full install + run_generator(sync: true) # then a sync run + end + + it "does not re-scaffold the docs registry or pages" do + # Delete a site-owned file, then sync: sync must NOT recreate it. + FileUtils.rm(File.join(destination, "app/models/doc.rb")) + FileUtils.rm(File.join(destination, "app/views/docs/pages/installation.rb")) + + run_generator(sync: true) + + expect(exist?("app/models/doc.rb")).to be(false) + expect(exist?("app/views/docs/pages/installation.rb")).to be(false) + end + + it "does not re-scaffold the site-owned CSS build" do + FileUtils.rm(File.join(destination, "app/assets/stylesheets/application.tailwind.css")) + + run_generator(sync: true) + + expect(exist?("app/assets/stylesheets/application.tailwind.css")).to be(false) + end + + it "still keeps the wiring in place (routes, stimulus, rubocop)" do + routes = read("config/routes.rb") + expect(routes).to include(%(get "docs/:doc(.:format)" => "docs#show", as: :doc)) + expect(read("app/javascript/controllers/index.js")) + .to include(%(eagerLoadControllersFrom("docs_kit/controllers", application))) + expect(read(".rubocop.yml")).to include("docs_kit/rubocop") + end + end + + context "when run on a fresh skeleton (no prior full install)" do + before { build_skeleton } + + it "wires routes without scaffolding site content" do + run_generator(sync: true) + + # Wiring happened... + expect(read("config/routes.rb")).to include(%("docs#show")) + # ...but no site-owned content was scaffolded. + expect(exist?("app/models/doc.rb")).to be(false) + expect(exist?("app/views/docs/pages/installation.rb")).to be(false) + end + + it "is idempotent: a second sync duplicates no routes" do + run_generator(sync: true) + run_generator(sync: true) + + routes = read("config/routes.rb") + expect(routes.scan(%(get "docs/:doc(.:format)" => "docs#show", as: :doc)).size).to eq(1) + end + end + end + + # Drift detection: `--sync` reads the site (string-level, conservatively) and + # warns about manual cleanup it can NOT safely automate — a hand-written + # `render_page` (DocsKit::Controller now provides it) and a dead `IconHelper` + # copy. It warns, never deletes, and always exits zero. + describe "--sync drift detection" do + # An ApplicationController that hand-defines render_page (the pre-generator + # pattern) — the audit's #1 drift item. + def seed_handwritten_render_page + write("app/controllers/application_controller.rb", <<~RUBY) + class ApplicationController < ActionController::Base + include DocsKit::Controller + + private + + def render_page(view) + render view, layout: false + end + end + RUBY + end + + it "warns when ApplicationController hand-defines render_page" do + build_skeleton + seed_handwritten_render_page + + output = capture_generator(sync: true) + + expect(output).to include("render_page") + expect(output).to include("DocsKit::Controller") + end + + it "warns when a dead IconHelper copy is present" do + build_skeleton + write("app/helpers/icon_helper.rb", "module IconHelper\nend\n") + + output = capture_generator(sync: true) + + expect(output).to include("IconHelper") + end + + it "does NOT delete the drifted files (warn, never auto-delete)" do + build_skeleton + seed_handwritten_render_page + write("app/helpers/icon_helper.rb", "module IconHelper\nend\n") + + capture_generator(sync: true) + + expect(exist?("app/controllers/application_controller.rb")).to be(true) + expect(read("app/controllers/application_controller.rb")).to include("def render_page") + expect(exist?("app/helpers/icon_helper.rb")).to be(true) + end + + it "reports a clean bill on a site with no drift" do + build_skeleton + run_generator # full install: ApplicationController only gets `include`, no render_page + + output = capture_generator(sync: true) + + expect(output).not_to include("render_page") + expect(output).not_to include("IconHelper") + end + end end