fix: docs-kit conforms to conformance-kit v0.4.0 (snapshot lock, auto-finalize, Ractor canary, strict audit)

Merged
MH
11d ago
+1277-94

Summary

docs-kit now passes conformance-kit v0.4.0 end to end. This PR started as the caller rollout: .github/workflows/conformance.yml (now at @v0.4.0), conformance-kit ~> 0.4 and .github/dependabot.yml. It also carries #91, so conformance / Conformance (Ruby 4.0) goes green on its merits.

Tier 1, thread-safe. RuboCop inherits conformance-kit: config/rubocop.yml, and rubocop-thread_safety >= 0.8 is in the bundle. Every offence was fixed; none was baselined.

  • DocsKit::Snapshot.for, the bug the audit found. The cache was an unguarded @cache ||= {}. When 32 threads hit a cold version together, they parsed manifest.json up to 26 times. The cache now lives in DocsKit::Store, which is per Ractor, and the check-then-set runs under a lock, so a cold version is parsed once. A Snapshot also builds its registries in initialize, so a cached instance has no lazy memo left to race on.
  • Configuration#openapi_document: the mtime memo goes through the same per-Ractor store, under a lock. Threads that missed together used to get up to 16 distinct Documents. Now they get one.
  • DocsKit.configuration is built at load instead of with ||=. MigrationRegistry.default is a constant built at load.
  • DocsUI::Page's class macros and the registry DSL make each value shareable when it is written. They only run in class bodies, at load.

Tier 2, fiber-safe. The new spec/docs_kit/contention_spec.rb runs DocsKit::Scope.with and an archived-version sidebar (Snapshot.for plus nav_groups) through "thread-safe under contention" and "fiber-safe under an Async reactor". Every call yields in the middle (Thread.pass / Async::Task#yield). async is now in the test group.

Tier 3, Ractor-clean.

  • Finalizing is automatic. DocsKit.finalize! runs from the engine's after_initialize in every environment, with no app code, after the host's to_prepare configure.
    • It resolves the Rouge themes and every registered page's view class once, into frozen maps, and fills the brand-logo, SEO, landing and mcp memos at boot.
    • With code reloading on, the engine also adds a to_prepare after boot. That hook runs after the host's to_prepare configure on every reload. The configure re-opens the configuration, then the hook re-finalizes it against the reloaded classes.
    • The host's per-request callables are kept as given and never frozen, so finalizing never fails on one. The README now says so, along with when finalizing happens and what to do outside Rails.
  • The canary runs SearchIndex, llms.txt / llms-full.txt and a literal registry's NavItem tree inside a non-main Ractor, on 4.0.
  • conformance-audit --strict (strict_autoloads) reports 0 findings. To get there:
    • DEFAULT_NAV, ApiClient::DEFAULTS (it held Method objects), Callout::LEVELS, Code::FORMATTER, Engine::JAVASCRIPT_PATH, MIGRATIONS and PACKAGE_FLOORS are now shareable.
    • MIN_RUBY and Migration#to keep a String, because Gem::Version.new returns process-wide cached instances.
  • The gem cannot depend on the kit at runtime. So DocsKit::Store is a copy of ConformanceKit::Store, and DocsKit::Shareable.make is a capability-gated Ractor.make_shareable.
  • Allowlist: 28 name: entries became 3 name: entries plus 3 ancestor: rules.
    • Rails' and Thor's class state on the engine, controllers and generators is now covered by three rules: Rails::Engine, ActionController::Base and Rails::Generators::Base. Each rule is narrowed with ivars: to that framework's own ivars.
    • docs-kit's own state keeps its name: entries: DocsKit.@configuration and the two Store fallback ivars.
    • The audit allowlists the same 28 names before and after. The diff of the two lists is empty, and nothing is listed as stale.

Test plan

These runs used CI-shaped bundles: the published daisyui, no sibling path gem, one lockfile per Ruby, with conformance-kit 0.4.0 resolved.

  • bundle exec conformance-kit check on Ruby 4.0.6 exits 0:
    conformance-kit doctor: ok (conformance-kit 0.4.0, .conformance.yml)
    ==> bundle exec rspec --tag ractor
    6 examples, 0 failures, 3 pending
    ==> conformance-audit --strict --config .conformance.yml
    conformance-audit: 0 findings in DocsKit, DocsUI (28 allowlisted main-only; 90 modules audited, 0 unloaded autoloads skipped)
    conformance-kit check passed
    
  • The allowlist hard rule. I ran conformance-audit --strict three times: on 0.3.0 with the old config, on 0.4.0 with the old config, and on 0.4.0 with the ancestor rules. Each run allowlists 28 names. The sorted name lists are identical, and no run has a stale section.
  • bundle exec rake on Ruby 3.3.8, 3.4.8 and 4.0.6, after the review fixes: 1174 examples, 0 failures each.
    • Pending: 6 on 3.3 and 3.4, where the canary skips below 4.0 with its reason. 3 on 4.0: the upstream-blocked render canaries.
    • Line coverage is about 93%.
    • RuboCop: 175 files, no offenses.
  • RED before GREEN for each fix:
    • Round 1:
      • The 32-thread snapshot spec parsed 26, 23 and 11 times on three runs.
      • The OpenAPI contention example returned 14 to 16 distinct Documents.
      • The contention spec's thread example fails on the old snapshot cache.
      • finalize_spec failed 9 of 9.
      • The canary failed on DEFAULT_NAV and on the registry's @pages.
      • The constants spec failed 7 of 10 without the fixes.
      • The page-macro and registry shareability specs failed.
    • Round 2:
      • engine_spec failed 2 of 4: "finalizes it with code reloading on" and "again after every code reload".
      • The new child-process boot_spec failed 2 of 4: the development boot and the reload both reported finalized: false.
    • Review fixes:
      • Changing view_namespace after finalize returned the old namespace's class.
      • The OpenAPI memo was not on the configuration.
      • The Store's Ractor-local fallback returned 16 distinct values to 16 racing threads.
  • spec/docs_kit/boot_spec.rb boots a real minimal Rails app, configured the way the README and the generator do it (DocsKit.configure in to_prepare). Its brand, nav, version_badge and importmap lambdas close over a mutable app object.
    • With reloading off, the app boots finalized.
    • The callables are the same objects, not shareable, and still called per request.
    • With reloading on, the app boots finalized, and it is finalized again after Rails.application.reloader.reload!.
  • The docs dogfood app (docs/, Ruby 3.4.11):
    • bundle exec rspec (request and Playwright system specs): 15 examples, 0 failures. RuboCop is clean.
    • RAILS_ENV=development bin/rails runner: finalized? is true at boot and true after reloader.reload!. After the reload, the cached page view is the reloaded class (equal? the current constant, not the pre-reload one), and the registry is the reloaded Doc.
  • CI on this PR (58d4ba7): all 8 checks are green. They cover rake on 3.3, 3.4 and 4.0, the docs site (RSpec + Playwright), the no-mcp gate and conformance / Conformance settings.
    • conformance / Conformance (Ruby 4.0) logged conformance-kit 0.4.0 satisfies the workflow's v0.4.
    • It then logged the same doctor, canary (6 examples, 0 failures, 3 pending) and audit (0 findings, 28 allowlisted) lines as the local run, then conformance-kit check passed.
    • The compiler probes are report-only.

Remaining from #91

  • Required checks in the branch ruleset (the 2026-10-15 update): add conformance / Conformance settings and conformance / Conformance (Ruby 4.0). That is a repo-settings change, left for the maintainer. It is also why this PR says Refs, not Closes.
  • Rendering inside a Ractor, out of scope per the issue. Three canaries are marked pending, each with its upstream reason, and each turns red the day it starts passing:
    • Phlex: Phlex::ATTRIBUTE_CACHE, yippee-fun/phlex#1055.
    • DocsUI::Markdown: commonmarker defines its options with unshareable Procs.
    • DocsKit::MarkdownExport: nokogiri's C extension raises Ractor::UnsafeError.

Deviations & judgment calls

  • DocsKit::Store instead of ConformanceKit::Store. The issue's Decision names the kit's store, but the kit is a development dependency only. I copied it (MIT, same author). Its two below-3.4 fallback ivars are allowlisted with the kit's own reasons.
  • DocsKit.@configuration is allowlisted main_only. By public API it holds host-supplied per-request callables (brand, nav, version_badge, importmap), so it can never be shareable. Deep-freezing it at boot would also break two things: the to_prepare reconfigure on a dev reload (c.seo.description = ... mutates the SeoConfig in place), and any host spec that calls DocsKit.configure. Per the issue's Decision, finalize! covers the derived readers. Code inside a Ractor builds its own Configuration and passes it in, which is what the canary does. The maintainer may want to weigh in on this one.
  • Generators are in the audit. It also loads lib/generators/**. That surfaced three docs-kit constants, now fixed. The Thor/Rails generator class state it brings in is covered by the Rails::Generators::Base rule.
  • Round 1 skipped finalize! while code reloading was on. Round 2 replaces that (see below). configure still re-opens a finalized config and its registries.
    • Only views that resolve are cached, so a page written after boot still shows up.
    • A theme assigned directly after finalize still wins, because the memo is checked against the identity of the current setting.
  • Behaviour changes, listed in the CHANGELOG:
    • DSL and macro values, and the gem's constants, are deep-frozen.
    • DocsKit.configuration exists from load.
    • A broken brand_logo now raises at boot, in every environment, not on first render.
  • The audit's minimal Rails app. The eager-load file is spec/conformance_eager_load.rb. It boots a minimal Rails app, because DocsUI::Page reads Rails.application.routes at load and the docs/ app has its own bundle. It then eager-loads only the gem's own zeitwerk loader, not eager_load_all.
  • The canary stage lifts the coverage floor. ractor.env sets SIMPLECOV_MINIMUM (default 80) to 0, because a --tag ractor run can't reach 80%.
  • Inline disables, each with its reason on the same or the previous line:
    • Page's macros (disable-next: written once, in the class body, at load).
    • DocsKit.reset_configuration! (boot and specs only).
    • The og task's one-shot screenshot Thread.new (a CLI; the thread is killed in ensure).
    • The store's fallback (verbatim from the kit).
    • The release-kit spec's block-form Dir.chdir.
  • NewThread exclude. spec/docs_kit/snapshot_contention_spec.rb starts threads on purpose. It is excluded from ThreadSafety/NewThread, with the reason in the config.
  • The 3.2 gate does not apply. The issue's gates list Ruby 3.2, but the gem's floor is 3.3 (required_ruby_version >= 3.3), so I left the floor alone. The new 4.0 lane is added to ci.yml's rake matrix.
  • Labels. chore is replaced by the issue's bug and core. devops stays. components, generators, openapi and dx come from bin/labels infer.

Round 2 (minor release, auto-finalize, conformance-kit 0.4):

  • Where auto-finalize hooks in. It stays in the engine's after_initialize, the latest boot hook the gem already used, which now finalizes in development too. Inside it, the engine registers app.reloader.to_prepare { DocsKit.finalize! } when reloading is on.
    • It is registered after boot on purpose. By then Rails has added every host config.to_prepare block to the reloader, so on each reload the host's configure runs first and the re-finalize second.
    • A config.to_prepare in the engine body would have run before the host's block instead.
  • Outside Rails, finalize! stays a documented call (README), rather than running at the end of DocsKit.configure.
    • docs-kit's natural boot point is the Rails boot: DocsUI::Page needs Rails routes.
    • configure runs on every dev reload and in every host spec, so finalizing there would only add a second pass inside Rails.
  • The configuration is not frozen at finalize. Freezing what can be frozen would break the dev-reload configure (c.seo.description = ... mutates in place). It would also buy no Ractor access, because the object stays main-only for its callables. What finalize resolves is frozen, as are the DSL and macro values.
  • No README or docs example raises at boot now. The brand_logo examples are plain strings or need a real file, and a file: mark still re-reads on mtime change after the boot memo.
  • No tracked lockfile pins the kit. The root Gemfile.lock is gitignored, and docs/Gemfile / docs/Gemfile.lock don't bundle conformance-kit. So no lockfile changed, and .github/dependabot.yml keeps directory: /.
  • CHANGELOG. The [Unreleased] entries now sit under ## [1.4.0], with an empty [Unreleased] above it as in the 1.3.0 cut. The heading has no date, which the release sets. It adds an "Upgrading from 1.3" note (nothing to change for a to_prepare initializer; three things now fail loudly).
  • Review thread (round 2 start). cubic's dependabot finding ("a group can still open a PR with only one pin") is dismissed as intended behaviour, with the reason: the version handshake fails such a PR until the other pin catches up, as the kit README's "Staying current" documents. The thread is resolved.

Review of abd71a8 (13 cubic findings, all resolved):

  • Fixed, behaviour (each with a failing spec first):
    • Registry: setting view_namespace after finalize now drops the cached view classes.
    • The OpenAPI memo moved onto the configuration, under a per-Ractor lock. A replaced configuration no longer stays alive in a per-Ractor map, with its Document.
    • DocsKit::Store's Ractor-local fallback (below 3.4, off the main Ractor): racing threads now all get the first value stored. That branch is now covered on every Ruby. conformance-kit's own ConformanceKit::Store has the same ||= [yield], so the kit's copy needs the same fix (not done here; another repo).
  • Fixed, other:
    • Code::FORMATTER is built with DocsKit::Shareable.make. It was already shareable with this Rouge, but the deep freeze holds for any Rouge in the gemspec's range.
    • The README claim now covers only resolved page views.
    • The const_get assertion is scoped to the two theme names.
    • shareable_spec skips its Ractor examples on an engine without Ractor.
    • page_spec checks the stored values, not just their shareability.
    • An empty SIMPLECOV_MINIMUM means the default floor.
    • Dependabot's grouped PRs get labels: ["dependencies", "devops"].
  • Dismissed as intended:
    • The tag pin, not a SHA: the version handshake reads job.workflow_ref.
    • ~> 0.4: the handshake only rejects an older gem.

Refs #91

https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU