fix: docs-kit conforms to conformance-kit v0.4.0 (snapshot lock, auto-finalize, Ractor canary, strict audit)
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 parsedmanifest.jsonup to 26 times. The cache now lives inDocsKit::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 ininitialize, 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.configurationis built at load instead of with||=.MigrationRegistry.defaultis 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'safter_initializein every environment, with no app code, after the host'sto_prepareconfigure.- 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_prepareafter boot. That hook runs after the host'sto_prepareconfigure 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.txtand a literal registry'sNavItemtree 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,MIGRATIONSandPACKAGE_FLOORSare now shareable.MIN_RUBYandMigration#tokeep a String, becauseGem::Version.newreturns process-wide cached instances.
- The gem cannot depend on the kit at runtime. So
DocsKit::Storeis a copy ofConformanceKit::Store, andDocsKit::Shareable.makeis a capability-gatedRactor.make_shareable. - Allowlist: 28
name:entries became 3name:entries plus 3ancestor:rules.- Rails' and Thor's class state on the engine, controllers and generators is now covered by three rules:
Rails::Engine,ActionController::BaseandRails::Generators::Base. Each rule is narrowed withivars:to that framework's own ivars. - docs-kit's own state keeps its
name:entries:DocsKit.@configurationand 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.
- Rails' and Thor's class state on the engine, controllers and generators is now covered by three rules:
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 checkon 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 --strictthree 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 rakeon 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_specfailed 9 of 9.- The canary failed on
DEFAULT_NAVand 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_specfailed 2 of 4: "finalizes it with code reloading on" and "again after every code reload".- The new child-process
boot_specfailed 2 of 4: the development boot and the reload both reportedfinalized: false.
- Review fixes:
- Changing
view_namespaceafter 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.
- Changing
- Round 1:
-
spec/docs_kit/boot_spec.rbboots a real minimal Rails app, configured the way the README and the generator do it (DocsKit.configureinto_prepare). Itsbrand,nav,version_badgeandimportmaplambdas 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?istrueat boot andtrueafterreloader.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 reloadedDoc.
- 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)loggedconformance-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 settingsandconformance / Conformance (Ruby 4.0). That is a repo-settings change, left for the maintainer. It is also why this PR saysRefs, notCloses. - 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 raisesRactor::UnsafeError.
- Phlex:
Deviations & judgment calls
DocsKit::Storeinstead ofConformanceKit::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.@configurationis allowlistedmain_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: theto_preparereconfigure on a dev reload (c.seo.description = ...mutates the SeoConfig in place), and any host spec that callsDocsKit.configure. Per the issue's Decision,finalize!covers the derived readers. Code inside a Ractor builds its ownConfigurationand 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 theRails::Generators::Baserule. - Round 1 skipped finalize! while code reloading was on. Round 2 replaces that (see below).
configurestill 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.configurationexists from load.- A broken
brand_logonow 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, becauseDocsUI::PagereadsRails.application.routesat load and thedocs/app has its own bundle. It then eager-loads only the gem's own zeitwerk loader, noteager_load_all. - The canary stage lifts the coverage floor.
ractor.envsetsSIMPLECOV_MINIMUM(default 80) to 0, because a--tag ractorrun 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 inensure). - The store's fallback (verbatim from the kit).
- The release-kit spec's block-form
Dir.chdir.
NewThreadexclude.spec/docs_kit/snapshot_contention_spec.rbstarts threads on purpose. It is excluded fromThreadSafety/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 toci.yml's rake matrix. - Labels.
choreis replaced by the issue'sbugandcore.devopsstays.components,generators,openapianddxcome frombin/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 registersapp.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_prepareblock to the reloader, so on each reload the host's configure runs first and the re-finalize second. - A
config.to_preparein the engine body would have run before the host's block instead.
- It is registered after boot on purpose. By then Rails has added every host
- Outside Rails,
finalize!stays a documented call (README), rather than running at the end ofDocsKit.configure.- docs-kit's natural boot point is the Rails boot:
DocsUI::Pageneeds Rails routes. configureruns on every dev reload and in every host spec, so finalizing there would only add a second pass inside Rails.
- docs-kit's natural boot point is the Rails boot:
- 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_logoexamples are plain strings or need a real file, and afile:mark still re-reads on mtime change after the boot memo. - No tracked lockfile pins the kit. The root
Gemfile.lockis gitignored, anddocs/Gemfile/docs/Gemfile.lockdon't bundle conformance-kit. So no lockfile changed, and.github/dependabot.ymlkeepsdirectory: /. - 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 ato_prepareinitializer; 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_namespaceafter 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 ownConformanceKit::Storehas the same||= [yield], so the kit's copy needs the same fix (not done here; another repo).
- Registry: setting
- Fixed, other:
Code::FORMATTERis built withDocsKit::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_getassertion is scoped to the two theme names. shareable_specskips its Ractor examples on an engine without Ractor.page_specchecks the stored values, not just their shareability.- An empty
SIMPLECOV_MINIMUMmeans 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.
- The tag pin, not a SHA: the version handshake reads
Refs #91