feat(core): concurrency conformance via conformance-kit v0.4.0

Merged
MH
1mo ago
+781-62

Summary

phlex-forms passes conformance-kit v0.4.0's gates: conformance / Conformance (Ruby 4.0) is green, and so are Lint and the Specs on 3.4 and 4.0. This PR adds the kit's caller workflow and makes the gem conform. That conformance work is tracked in #42. Ships as 0.5.0 (## [0.5.0] in the CHANGELOG).

  • Tier 1 (RuboCop): .rubocop.yml inherits conformance-kit: config/rubocop.yml, and the Gemfile adds rubocop-thread_safety >= 0.8. All 22 ThreadSafety/ClassInstanceVariable offences in lib/ are fixed, and nothing is baselined. Three inline disables remain, each with its reason:
    • lib/phlex_forms.rb: one disable-next on the module's class << self (configuration accessors). @configuration is built at load and replaced only on the main Ractor, at boot or between tests. It is shareable once finalized.
    • lib/forms/base.rb: one disable/enable pair around form_options and its two readers. form_options is a class macro that runs while the class body loads, and it stores shareable copies.
    • lib/forms/live.rb: one pair around the live/live_permit/live_deny/live_tags macros and their readers, for the same reason.
  • Tiers 1–2 (contention): spec/phlex_forms/contention_spec.rb runs the kit's thread and Async examples over Theme.resolve, with consistent: :identical.
  • Tier 3 (finalize at boot, automatic):
    • Under Rails, the engine calls PhlexForms.finalize! in config.after_initialize, after every initializer has run, with no app code.
    • Without Rails (no Rails::Engine when the gem loads), PhlexForms.configure finalizes when its block returns. A host that never calls configure calls PhlexForms.finalize! at boot (documented).
    • finalize! stores Ractor-shareable copies of every setting and then freezes the configuration. The caller's own arrays and hashes are copied, never frozen.
    • Finalizing never raises over a value it cannot share. That value is kept as given and works on the main Ractor only, for example a top-level initializer lambda (its self is main) or one that closes over a Logger.
    • New this round: an icon renderer that is not a Proc (an object with #call, a Method) is never copied or frozen. Before, make_shareable(copy: true) replaced a renderer that keeps a cache with a frozen copy, and that copy raised FrozenError on its first render.
    • configure on a finalized configuration edits a copy and swaps it in. A direct write after boot raises FrozenError. A direct write in a Rails initializer, before boot, still works.
    • Theme::DAISY/Theme::PLAIN are built once, when theme.rb loads, and every Theme is frozen. form_options and the live macros store shareable copies.
  • Ractor canary (Ruby 4.0): spec/phlex_forms/ractor_canary_spec.rb (:ractor) runs these inside a non-main Ractor: field option resolution and inference on a Struct model, theme resolution, the icon renderer, and the form_options/live settings. Rendering is a pending example with the Phlex reason.
  • Audit: audit.require: [./spec/conformance_eager_load.rb] eager-loads both Zeitwerk roots and calls finalize!. The audit runs with namespace: [PhlexForms, Forms] and strict_autoloads: true. There is no main_only and no skip_autoloads.
  • Bugs fixed along the way:
    • Without phlex-reactive, the first form raised NameError: Forms::TagField. phlex-reactive is meant to be a soft dependency; in practice it was a hard one.
    • Without Rails but with phlex-reactive bundled, the first form raised NoMethodError: Hash#to_json. Because finalizing builds the themes, configure now raised it too. The cause is phlex-reactive's Sortable, which does not require json. phlex-forms now requires json before phlex-reactive (spec/phlex_forms/plain_boot_spec.rb).
    • Review fixes: configure returns its block's value again. field_variants = nil is stored as [], so a later << is no longer lost.
  • bundle exec rake passes from the root. It used to abort on main. The root RuboCop now excludes docs/, a separate Rails app with its own bundle and .rubocop.yml. The Gemfile's one Bundler/OrderedGems offence is fixed.

Refs #42

Test plan

  • bundle exec conformance-kit check, Ruby 4.0.6, local, kit 0.4.0. It exits 0:
    • doctor: ok (conformance-kit 0.4.0, .conformance.yml)
    • bundle exec rspec --tag ractor: 6 examples, 0 failures, 1 pending (the render canary)
    • conformance-audit: 0 findings in PhlexForms, Forms (0 allowlisted main-only; 70 modules audited, 0 unloaded autoloads skipped)
    • conformance-kit check passed
  • conformance-audit --strict before the bump (0.3.0) and after it (0.4.0) gives the same result: 0 allowlisted main-only names, 0 ancestor: rules. The allowlisted set gained no name.
  • bundle exec rspec, Ruby 4.0.6: 241 examples, 0 failures, 2 pending (the render canary and the existing TimeZoneSelect skip).
  • bundle exec rspec, Ruby 3.4.11: 241 examples, 0 failures, 7 pending. The 6 Ractor examples are skipped with the kit's reason.
  • bundle exec rubocop lib spec (what CI lints) on 4.0.6 and 3.4.11: 99 files, no offences. bundle exec rake exits 0 on both Rubies.
  • New this round, each seen failing first:
    • spec/phlex_forms/rails_boot_spec.rb boots a minimal Rails app in a child process. It checks three things: the default configuration is frozen and shareable after boot with no app code; initializer writes (configure and a direct write) are kept; and a top-level lambda renderer that closes over a Logger boots and still renders. With the engine's after_initialize line removed, it failed 3 of 3 (frozen: false).
    • finalize_spec "keeps a renderer object that holds its own state". It failed before the fix: the renderer had been replaced by a frozen copy.
    • plain_boot_spec boots without Rails in a child process with json not preloaded. Before the fix it failed with NoMethodError: Hash#to_json from configure.
    • finalize_spec "returns the block's value" and "keeps field_variants appendable after it is set to nil": 2 failures before the fixes.
  • Round 1's checks were also seen failing first: the RuboCop offences, finalize_spec, the canary, the audit, the soft-dependency spec and the contention spec.
  • CI on e17c21f: Lint, Specs (3.4), Specs (4.0), Conformance settings, Conformance (Ruby 4.0) (handshake conformance-kit 0.4.0 satisfies the workflow's v0.4), Compiler probes and cubic all pass. 0 unresolved review threads.

Remaining from #42

  • Rendering inside a non-main Ractor. Phlex reads its non-shareable Phlex::ATTRIBUTE_CACHE constant on every element that has attributes. A per-Ractor cache is pending upstream in yippee-fun/phlex#1055. The canary's render example is pending with that reason, so RSpec reports it once Phlex lands. Under the daisy theme, rendering also hits daisyui's class-level modifier maps (DaisyUI::Input.@modifiers). That part belongs to daisyui's own rollout.
  • Required status checks. conformance / Conformance settings and conformance / Conformance (Ruby 4.0) still need adding to the branch ruleset. That is for the maintainer: this PR changes no repo settings.

Deviations & judgment calls

Round 1:

  • No main_only for Forms::Base. Its class-body ivars are made shareable when written, with a commented inline disable. The audit never sees host subclasses, so an entry would be stale.
  • Themes are built when theme.rb loads, not inside finalize!. finalize! resolves the default theme, so they still load at boot. Theme::DAISY and Theme::PLAIN are new public constants.
  • The configuration stays open until it is finalized, per the issue's Decision. A direct write after boot raises FrozenError; configure keeps working.
  • One default icon renderer for every Ruby: a capture-free lambda made shareable in the class body. On 3.4, make_shareable rejects a Method.
  • Forms::Live.@_dependencies is frozen, not listed in main_only. ActiveSupport::Concern only reads it after the module body has loaded.
  • register_element → tag() in RichTextarea. Phlex 2.0.0, the gemspec floor, already has tag.
  • The contention spec checks Theme.resolve. The issue names Theme.component_for, which doesn't exist.
  • The canary covers more than field inference. It uses a Struct model, as the issue says.
  • New development-only piece: the async gem. Round 1 also added a require "json" to the eager-load file; round 2 moved it into the gem.
  • rubocop:disable-next needs RuboCop 1.92 or later. No lockfile is tracked, so CI resolves the latest RuboCop.
  • No ConformanceKit::Store or Mutex pattern was needed: no lazy state is left.
  • The docs-site Configuration page is not updated. Only the README and CHANGELOG are, because a docs-page change needs screenshots.

Round 2:

  • Auto-finalize was already wired in round 1 (the engine's after_initialize, plus the end of configure without Rails). This round adds the real-boot proof (rails_boot_spec) instead of only the stub_const spec. It runs in a child process, so the engine never loads into the suite. Without the engine, configure finalizes by itself.
  • railties ~> 8.0 added to the development group. The boot spec requires it. It was already in the bundle through phlex-reactive, so the resolution is unchanged.
  • Non-Proc icon renderers are kept as given (PhlexForms::Shareable.callable). The other choice was to share them in place with make_shareable. That would freeze the host's own object and break it the same way.
  • The "outside Rails" boot point is the end of configure, not require. Finalizing at require would load every component at require time (round 1 hit phlex-reactive's missing require "json" doing that). It would also freeze a configuration the host has not configured yet. A host that never calls configure, or loads Rails without booting an app, calls PhlexForms.finalize! (README).
  • No main_only entries to convert to ancestor: rules. There were none before (0 allowlisted), and there are none after. The audit's scope is unchanged: booting the engine into it would have added Rails::Engine class state to allowlist.
  • Dependabot is unchanged. No Gemfile besides the root pins conformance-kit (docs/Gemfile doesn't), and the root Gemfile.lock is not tracked, so there was no lockfile to update.
  • CHANGELOG: this repo never had version headings, and released 0.4.x entries still sat under [Unreleased]. This PR's entries move to a new ## [0.5.0] section, which opens with an "Upgrading to 0.5" list. The released pile below it is headed ## [0.4.1] and earlier; otherwise it is untouched.
  • bundle exec rake: docs/ is excluded from the root RuboCop (comment in .rubocop.yml). The Gemfile is re-sorted: phlex-reactive's block moved below gem-release.
  • Cubic findings:
    • The one-sided Dependabot group PR is intended behaviour. Dependabot cannot wait for both ecosystems; the version handshake fails a one-sided PR, as conformance-kit's README documents.
    • "patterns is invalid at the update-entry level" is a false positive. GitHub's multi-ecosystem docs require it there.
    • Three findings are fixed: the CHANGELOG/README wording on Ractor shareability, configure's return value, and nil field_variants. For the last one, the next configure did not actually fail (nil.dup is nil), but a << after nil was lost.
  • The json require lives in the gem. The eager-load file's workaround was dropped. phlex-reactive's own fix can't be relied on: it is unreleased.

https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU