Repository navigation
feat(core): concurrency conformance via conformance-kit v0.4.0 - #45
Merged
Merged
Conversation
Adds the caller, bumps conformance-kit to ~> 0.3 and lets Dependabot keep both pins current. The conformance check is not required yet: it turns green, and becomes required, with #42. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
A tag bump without the gem (or the reverse) fails the conformance version check, so both now arrive in a single multi-ecosystem pull request. Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
There was a problem hiding this comment.
All reported issues were addressed across 1 file (changes from recent commits).
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
cubic-dev-ai
Bot
dismissed
their stale review
October 15, 2026 15:31
Dismissed because Cubic found issues in a newer review.
mhenrixon
marked this pull request as draft
October 15, 2026 15:33
Adds rubocop-thread_safety (>= 0.8, what conformance-kit doctor checks) and inherits conformance-kit's config/rubocop.yml, so the Tier 1 ThreadSafety cops run in the Lint job. The offences it reports in lib/ are fixed in the commits that follow, not baselined. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
Theme.daisy / Theme.plain named Forms::TagField and its rootless twin as arguments to reactive_roles, so the classes were resolved before the defined?(Phlex::Reactive) guard ran. Without phlex-reactive those files are ignored by the loader, and the first theme lookup raised NameError: the soft dependency was in fact hard. reactive_roles now takes the namespace and names the classes only behind the guard. The spec boots the gem in a child process with phlex-reactive off the load path. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
The configuration and both built-in themes lived in lazy class ivars (`@configuration ||=`, `@daisy ||=`, `@plain ||=`, plus lazy defaults in Configuration's readers): check-then-set races, and unreadable from a non-main Ractor. - Theme.daisy / Theme.plain are the DAISY / PLAIN constants, built once when theme.rb loads; a Theme is frozen on construction, so a theme of component classes is Ractor-shareable. - Configuration sets every default in initialize; no reader memoizes. The default icon renderer is a capture-free lambda made shareable in the class body, and glyphs_renderer returns a shareable one too. - PhlexForms.finalize! stores Ractor-shareable copies of every setting (PhlexForms::Shareable.copy, which never freezes the caller's objects) and freezes the configuration. configure calls it when the Rails engine is absent; the engine calls it after_initialize. configure on a finalized configuration edits a copy and swaps it in whole. - A renderer that cannot be shared (a top-level lambda in an initializer) is kept as given and still works on the main Ractor. - The configuration is built when the gem loads, never lazily; one inline ThreadSafety/ClassInstanceVariable disable covers the module's configuration accessors, with the reason beside it. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
conformance-kit's "thread-safe under contention" and "fiber-safe under an Async reactor" examples over Theme.resolve, the lookup every field makes, require one Theme object across 8 threads/fibers x 50 calls. On the lazy `@daisy ||=` themes the thread example saw 8 distinct Theme objects in 2 of 3 runs; on the load-time constants it cannot race. Adds async (development only, require: false) so the fiber example runs instead of skipping. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
form_options kept its arguments as-is in the form class's ivars, so a non-main Ractor reading form_modifiers / form_defaults raised Ractor::IsolationError. It now stores PhlexForms::Shareable.copy of both (deep-frozen copies; the caller's objects stay unfrozen, and a value that cannot be shared, such as a lambda with an unshareable self, is kept as given for the main Ractor). The readers already build new values per call, so nothing else changes. The ThreadSafety/ClassInstanceVariable disable around the macro and its two readers carries the reason: written while the class body loads, at boot on the main Ractor, and shareable once written. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
live (setup_live), live_permit, live_deny and live_tags stored mutable strings, arrays and a hash in the form class's ivars, unreadable from a non-main Ractor. They now store PhlexForms::Shareable.copy of each; the model class and the debounce were shareable already. conformance-audit also flagged Forms::Live.@_dependencies, the Array ActiveSupport::Concern keeps of the concerns Forms::Live includes. It is filled while the module body loads and only read after (when a form class includes Forms::Live), so it is frozen at the end of the body. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
register_element :lexxy_editor left Forms::RichTextarea with a mutable per-class element registry (@__registered_elements__, Phlex's own), which conformance-audit reports as unshareable class state. Phlex's tag(:lexxy_editor, ...) (Phlex >= 2.0.0, the gemspec floor) renders the same custom element, attributes and block, with no registry on the class. The rich-textarea specs are unchanged and green. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
conformance-kit's "runs inside a non-main Ractor" (tagged :ractor, Ruby 4.0; skipped with the kit's reason below) runs, with the configuration finalized: Forms::Field option resolution and PhlexForms::Inference on a literal Struct model, theme resolution and the icon renderer, and the form_options / live class settings of constant form classes. Before the two previous fixes the class-settings examples raised Ractor::IsolationError on @form_modifiers and @live_scope. Rendering is a pending example with its reason: Phlex reads its non-shareable Phlex::ATTRIBUTE_CACHE on every element with attributes (yippee-fun/phlex#1055). It fails today; once upstream lands the pending example passes and RSpec reports it, so it cannot go stale silently. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
audit.require points at spec/conformance_eager_load.rb, which eager-loads both Zeitwerk roots (PhlexForms, Forms) and finalizes the configuration as a host's boot does; audit.strict_autoloads makes the strict audit fail on any autoload left unloaded. No main_only and no skip_autoloads entries: nothing is left that needs one. The Ractor gate runs the :ractor examples; probes.gem_name enables the report-only compiler probes. `bundle exec conformance-kit check` on Ruby 4.0.6: doctor ok, 6 :ractor examples (0 failures, 1 pending: rendering, yippee-fun/phlex#1055), and conformance-audit: 0 findings, 70 modules audited, 0 unloaded autoloads. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
README: a "Configuration is finalized at boot" section (when it finalizes, writing through configure afterwards, renderers that cannot be shared). CHANGELOG [Unreleased]: PhlexForms.finalize! and the shareable themes and class macros (Added), the frozen configuration and the renderer rule (Changed), and the theme fix without phlex-reactive (Fixed). Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
Moves both pins together: the caller's tag and the Gemfile's ~> 0.4, so the version handshake matches. No tracked lockfile pins the kit (the root Gemfile.lock is ignored, docs/ does not bundle it). Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
Boots a minimal Rails app in a child process (so the engine never loads into the suite) and checks the configuration is frozen and shareable after after_initialize with no app code, that initializer writes (configure and a direct write) are kept, and that an icon renderer which cannot be shared (a top-level lambda closing over a Logger) boots and still renders. Seen failing with the engine's after_initialize removed. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
Shareable.copy deep-copies and freezes any object it can, so an icon renderer object with state of its own (a cache) was swapped for a frozen copy that raised FrozenError on its first render. A renderer that is not a Proc is now kept as given: shared only if it already is. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
The root RuboCop walked docs/, whose .rubocop.yml requires docs_kit/rubocop from the docs app's own bundle, so `rake` (spec + rubocop) aborted on main. docs/ is excluded from the root lint, and the one remaining offence (Bundler/OrderedGems in the Gemfile) is fixed by moving phlex-reactive into order. Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
Moves this PR's entries out of the [Unreleased] pile into a [0.5.0] section with an "Upgrading to 0.5" list (auto-finalize, FrozenError on a direct write after boot, unshareable values kept as given), and heads the released pile "[0.4.1] and earlier". The README says when finalizing happens with and without Rails, and that non-Proc renderers are never copied or frozen. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
mhenrixon
marked this pull request as ready for review
October 15, 2026 20:07
There was a problem hiding this comment.
All reported issues were addressed across 23 files
Reply with feedback, questions, or to request a fix.
View guided diff | Turn on auto-fix | Re-trigger cubic
Finalizing builds the themes, which load the tag-field leaves and with them phlex-reactive's Sortable, which calls Hash#to_json while it loads without requiring json. A Rails host has json loaded; a plain Ruby host got NoMethodError from PhlexForms.configure (before this PR, from its first form). phlex-forms now requires json before phlex-reactive, and the eager-load file's workaround goes. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
Before finalizing, configure was `yield(configuration)` and returned what the block returned; the finalizing versions returned the configuration or nil. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
The reader stopped memoizing `@field_variants ||= []`, so after `c.field_variants = nil` a later `c.field_variants << :sm` appended to a throwaway array. The writer now stores nil as an empty list. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
The changelog and README promised a Ractor-shareable configuration even when an unshareable value (a top-level renderer lambda) is kept as given. Also adds the json fix to the 0.5.0 Fixed list. Refs #42 Claude-Session: https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU
This was referenced Oct 16, 2026
Closed
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
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)..rubocop.ymlinheritsconformance-kit: config/rubocop.yml, and the Gemfile addsrubocop-thread_safety >= 0.8. All 22ThreadSafety/ClassInstanceVariableoffences inlib/are fixed, and nothing is baselined. Three inline disables remain, each with its reason:lib/phlex_forms.rb: onedisable-nexton the module'sclass << self(configuration accessors).@configurationis 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 aroundform_optionsand its two readers.form_optionsis a class macro that runs while the class body loads, and it stores shareable copies.lib/forms/live.rb: one pair around thelive/live_permit/live_deny/live_tagsmacros and their readers, for the same reason.spec/phlex_forms/contention_spec.rbruns the kit's thread and Async examples overTheme.resolve, withconsistent: :identical.PhlexForms.finalize!inconfig.after_initialize, after every initializer has run, with no app code.Rails::Enginewhen the gem loads),PhlexForms.configurefinalizes when its block returns. A host that never callsconfigurecallsPhlexForms.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.selfismain) or one that closes over aLogger.#call, aMethod) is never copied or frozen. Before,make_shareable(copy: true)replaced a renderer that keeps a cache with a frozen copy, and that copy raisedFrozenErroron its first render.configureon a finalized configuration edits a copy and swaps it in. A direct write after boot raisesFrozenError. A direct write in a Rails initializer, before boot, still works.Theme::DAISY/Theme::PLAINare built once, whentheme.rbloads, and everyThemeis frozen.form_optionsand thelivemacros store shareable copies.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 theform_options/livesettings. Rendering is apendingexample with the Phlex reason.audit.require: [./spec/conformance_eager_load.rb]eager-loads both Zeitwerk roots and callsfinalize!. The audit runs withnamespace: [PhlexForms, Forms]andstrict_autoloads: true. There is nomain_onlyand noskip_autoloads.NameError: Forms::TagField. phlex-reactive is meant to be a soft dependency; in practice it was a hard one.NoMethodError: Hash#to_json. Because finalizing builds the themes,configurenow raised it too. The cause is phlex-reactive'sSortable, which does not require json. phlex-forms now requires json before phlex-reactive (spec/phlex_forms/plain_boot_spec.rb).configurereturns its block's value again.field_variants = nilis stored as[], so a later<<is no longer lost.bundle exec rakepasses from the root. It used to abort onmain. The root RuboCop now excludesdocs/, a separate Rails app with its own bundle and.rubocop.yml. The Gemfile's oneBundler/OrderedGemsoffence is fixed.Refs #42
Test plan
bundle exec conformance-kit check, Ruby 4.0.6, local, kit 0.4.0. It exits 0: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 passedconformance-audit --strictbefore the bump (0.3.0) and after it (0.4.0) gives the same result: 0 allowlisted main-only names, 0ancestor: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 existingTimeZoneSelectskip).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 rakeexits 0 on both Rubies.spec/phlex_forms/rails_boot_spec.rbboots 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 (configureand a direct write) are kept; and a top-level lambda renderer that closes over aLoggerboots and still renders. With the engine'safter_initializeline 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_specboots without Rails in a child process with json not preloaded. Before the fix it failed withNoMethodError: Hash#to_jsonfromconfigure.finalize_spec"returns the block's value" and "keeps field_variants appendable after it is set to nil": 2 failures before the fixes.finalize_spec, the canary, the audit, the soft-dependency spec and the contention spec.e17c21f: Lint, Specs (3.4), Specs (4.0), Conformance settings, Conformance (Ruby 4.0) (handshakeconformance-kit 0.4.0 satisfies the workflow's v0.4), Compiler probes and cubic all pass. 0 unresolved review threads.Remaining from #42
Phlex::ATTRIBUTE_CACHEconstant on every element that has attributes. A per-Ractor cache is pending upstream in Give each Ractor its own attribute cache so components render on non-main Ractors yippee-fun/phlex#1055. The canary's render example ispendingwith 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.conformance / Conformance settingsandconformance / 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:
main_onlyforForms::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.theme.rbloads, not insidefinalize!.finalize!resolves the default theme, so they still load at boot.Theme::DAISYandTheme::PLAINare new public constants.FrozenError;configurekeeps working.make_shareablerejects aMethod.Forms::Live.@_dependenciesis frozen, not listed inmain_only. ActiveSupport::Concern only reads it after the module body has loaded.register_element→tag()inRichTextarea. Phlex 2.0.0, the gemspec floor, already hastag.Theme.resolve. The issue namesTheme.component_for, which doesn't exist.asyncgem. Round 1 also added arequire "json"to the eager-load file; round 2 moved it into the gem.rubocop:disable-nextneeds RuboCop 1.92 or later. No lockfile is tracked, so CI resolves the latest RuboCop.ConformanceKit::Storeor Mutex pattern was needed: no lazy state is left.Round 2:
after_initialize, plus the end ofconfigurewithout Rails). This round adds the real-boot proof (rails_boot_spec) instead of only thestub_constspec. It runs in a child process, so the engine never loads into the suite. Without the engine,configurefinalizes by itself.railties ~> 8.0added to the development group. The boot spec requires it. It was already in the bundle through phlex-reactive, so the resolution is unchanged.PhlexForms::Shareable.callable). The other choice was to share them in place withmake_shareable. That would freeze the host's own object and break it the same way.configure, notrequire. Finalizing atrequirewould load every component at require time (round 1 hit phlex-reactive's missingrequire "json"doing that). It would also freeze a configuration the host has not configured yet. A host that never callsconfigure, or loads Rails without booting an app, callsPhlexForms.finalize!(README).main_onlyentries to convert toancestor: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.docs/Gemfiledoesn't), and the rootGemfile.lockis not tracked, so there was no lockfile to update.[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 belowgem-release.patternsis invalid at the update-entry level" is a false positive. GitHub's multi-ecosystem docs require it there.configure's return value, and nilfield_variants. For the last one, the nextconfiguredid not actually fail (nil.dupis nil), but a<<after nil was lost.https://claude.ai/code/session_01SXwrW19u1qWNW3fm2pUnrU