fix: circular require warning, partial-migration constant crash, silent deprecation strategy - #237
Conversation
Reviewer's GuideFixes three production-surfaced issues: removes a circular require between core files, makes all legacy constants resolve correctly under canonical-only requires (including eager aliases), and implements a visible-but-controllable deprecation/migration notice strategy, with updated documentation and subprocess-based guards. Sequence diagram for canonical and legacy compatibility loadingsequenceDiagram
participant App
participant EntryPoint
participant LegacyShims
participant SnapDiff
participant Utils
App->>EntryPoint: require entry point
EntryPoint->>LegacyShims: require snap_diff/legacy_shims
LegacyShims->>SnapDiff: define eager aliases
App->>LegacyShims: reference legacy constant
LegacyShims->>LegacyShims: LegacyShims.resolve(old_name, target)
LegacyShims->>LegacyShims: require_unit(target)
LegacyShims->>SnapDiff: Object.const_get(target)
SnapDiff-->>App: same canonical object
App->>SnapDiff: Drivers.for(driver_options)
SnapDiff->>Utils: require snap_diff/utils
SnapDiff->>Utils: find_driver_class_for(driver_option)
Utils-->>SnapDiff: driver class
Sequence diagram for the once-per-process legacy migration noticesequenceDiagram
participant App
participant LegacyConfig
participant LegacyShim
participant Deprecation
participant SnapDiff
App->>LegacyConfig: read or write legacy setting
LegacyConfig->>Deprecation: notice
Deprecation->>SnapDiff: silence_deprecations?
Deprecation-->>App: one migration notice
App->>LegacyShim: resolve legacy constant
LegacyShim->>Deprecation: warn(subject, replacement)
Deprecation->>Deprecation: notice
Deprecation-->>App: per-constant warning once
App->>LegacyConfig: read another legacy setting
LegacyConfig->>Deprecation: notice
Deprecation-->>App: no additional migration notice
File-Level Changes
Possibly linked issues
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Warning Review limit reached
Next review available in: 43 minutes Limit details: You’ve used the included review currently available. You've used all free OSS reviews for now. Wait for the free limit to reset to keep reviewing this public repository. How can I continue?Wait for the limit to reset, then comment An organization admin can change what happens after included review limits in Billing. How do review limits work?CodeRabbit enforces per-developer PR review limits within each organization. For paid Pro and Pro+ reviews, CodeRabbit uses a developer's included PR review attempts over the past 7 days to set the current hourly allowance. At typical activity levels, the full plan allowance applies. Higher sustained activity can lower the allowance until earlier attempts leave the 7-day window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: defaults Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (14)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
…nt deprecation strategy Three defects found by running the gem against a real consumer project (jetthoughts.github.io, upgraded in Docker against its committed baselines). 1. drivers.rb <-> utils.rb formed a require cycle. Harmless functionally, but Ruby shouts "circular require considered harmful" with an 18-frame backtrace whenever $VERBOSE is on -- which Rake::TestTask sets by default, i.e. every standard Rails/Minitest suite. Deferred the require into Drivers.for, the only method that needs Utils. 2. A canonical require left most of the v1 constant surface unresolvable, including Capybara::Screenshot::Os, which killed a real suite before a test ran. Two causes, both fixed at the class level: the eager aliases lived in lib/capybara* forwarders that only legacy entry points load (moved to legacy_shims, which every entry point loads), and the const_missing shims named their replacement without loading it (they now require the target unit, and raise a NameError naming the SnapDiff name and UPGRADING.md if it truly cannot load). 3. Exercising all 14 legacy APIs a real setup file touches produced zero warnings: Deprecation.warn was reachable only through const_missing, so config delegators and eager aliases were structurally silent. Added one migration notice per process, fired from every hookable door, and corrected the docs that claimed every legacy name warns.
… load Review follow-up. The unconditional `autoload :VipsDriver` made `const_defined?(:VipsDriver)` true on a box without ruby-vips, where `const_get` then raises. Neither driver gem is a runtime dependency and the documented v1 pattern is `Diff.driver = :vips if defined?(...Drivers::VipsDriver)`, so that branch started being taken and then dying. v1.12.0 loaded vips_driver.rb only from find_driver_class_for, so `defined?` was nil there. Both leaves are now declared only when their gem is present. Also: `autoload :Utils` instead of a deferred require inside Drivers.for. Removing the bottom-of-file require narrowed the surface -- `require "snap_diff/drivers"` stopped defining SnapDiff::Utils, which drivers_test caught. Autoload breaks the cycle without narrowing anything. And `Capybara::Screenshot::Diff.default_options`, a documented v1 read, is hand-written rather than a generated delegator, so it fired no migration notice. One line, plus its own row in the per-door guard table.
80e28f3 to
7c4da0e
Compare
Screenshot diffs detected
|
`rake test:canonical` is defined as "exactly what must still pass once test/legacy/ and the v1 trees are gone". Three times in one day a test asserting LEGACY behaviour was written into test/unit/, i.e. into that suite: a canonical surface table demanding the shim-only SnapDiff.start (#236), three legacy-constant probes in a canonical file (#237), and a pre-existing umbrella guard #236 had to relocate. Each would have failed the day the deletion landed, long after its author moved on. Reviews caught all three; the fourth would ship. The test-tree twin of core_tree_has_no_legacy_deps_test.rb: no file under test/unit/ or test/integration/ may require a doomed path, name a v1 namespace constant, or use a shim-only name (SnapDiff.start, .silence_deprecations, SnapDiff::Deprecation, suppress_migration_notice!). test/legacy/ is deliberately not policed -- exercising the legacy surface is its job. Same conventions as the twin: file:line: reason -- `code`, whole-line comments ignored, a vacuity guard, and a sub-test that fails on stale allowlist entries. The allowlist holds two entries, both gates rather than tests of behaviour (deletion_3_0_test.rb, which names the deletion set by construction, and the twin gate's own pattern literal). A third entry means canonical tests are still entangled and needs a decision, not a green build. This file cannot scan itself: a line-level allowlist has to quote the lines it blesses, and every quote is itself an offence -- no fixed point exists.
Three defects surfaced by running the gem against a real consumer project — jetthoughts.github.io, upgraded in Docker against its committed visual baselines. All three were invisible to our own suite; each now has a subprocess guard, mutation-checked.
Rebased onto
64db3ea. Test numbers on that base:rake test:unit570, 0 failures;rake test598, 0 failures, 1 skip;rake test:canonical(the 3.0 gate) 467, 0 failures, 1 skip.standardrbclean; reverse gate + alias-only gate green. No version/CHANGELOG changes.Probe placement follows the #236 split. Canonical claims (the circular-require check over
CANONICAL_ENTRY_POINTS) live intest/unit/support_load_probe_test.rb, withverbose_loadpromoted todef self.verbose_loadalongside the existingself.probe. Everything asserting a v1 name —LEGACY_CONSTANTS, the legacy-constant resolution test, the vips-leaf test, the NameError-message test and the all-legacy-entries circular test — lives intest/legacy/legacy_entry_point_probe_test.rb, which reuses both class methods.CANONICAL_ENTRY_POINTSkeeps its name (#238's deletion test depends on it). Net effect: nothing inrake test:canonicalasserts a name 3.0 deletes.Defect 1 — circular require warning (regression we introduced)
Reproduction.
$VERBOSEon — whichRake::TestTasksets by default, i.e. every standard Rails/Minitest setup, and how the real project runs:drivers.rb:65requiredsnap_diff/utils;utils.rb:3requiredsnap_diff/driversback. Fired on every entry point, canonical and legacy, and survived into the 3.0 simulation. v1.12.0 emits none. Reproduced on Linux in their container — not a macOS artifact.Fix.
autoload :Utils, "snap_diff/utils"onSnapDiff, replacing the bottom-of-file require. Autoload never fires at load time, so the cycle is gone; and unlike simply deleting the require, it does not narrow the surface —require "snap_diff/drivers"still leavesSnapDiff::Utilsresolvable, whichdrivers_test.rbalready asserted and which my first attempt broke.Guards.
support_load_probe_test.rb→ "no canonical entry point emits a circular require warning under -w";legacy_entry_point_probe_test.rb→ the same over the v1 entries. A freshruby -wprocess per entry point, since Ruby only warns once per cycle per process.Mutation. Restored the bottom-of-file require → guards fail naming all 16 entry points:
After: all 16 entry points,
grep -c "circular require"→0.Defect 2 —
Capybara::Screenshot::Oshard-crashes after a canonical requireReproduction. UPGRADING.md tells adopters to migrate the
requireline first and rename constants afterwards. That half-migrated state was broken, and asymmetrically so — config setters kept working, so the mixed state looked supported right untilOsdied:The real project has one such reference. It aborted their entire suite before a single test ran.
Full audit — every legacy constant under a canonical-only require
38 legacy constants a consumer might touch, probed against each canonical entry point. Measured against
origin/master(64db3ea):snap_diffsnap_diff/dslsnap_diff/integrations/minitestsnap_diff/integrations/rspecsnap_diff/staticsnap_diff-capybara(
snap_diff/integrations/cucumberis not measurable this way — a bare require dies without cucumber'sWorld.)Three distinct causes, not two:
Class A — the
const_missingshim resolved its mapping, then died on the target. The v1 entry points required the whole gem, so v1 code could name these with nothing else required; the canonical entries are lean, so the shim leaked a bareuninitialized constant SnapDiff::Utils— an internal name the reader cannot act on.Diff::StableScreenshoter,Diff::AreaCalculator,Diff::Utils,Diff::ScreenshotMatcher,Drivers::BaseDriver,CapybaraScreenshotDiff::AttemptsReporter,Reporters::HTML.Class B — eager aliases assigned in
lib/capybara*forwarder files that only LEGACY entry points load. A canonical require never loaded the forwarder, so the name simply did not exist:Capybara::Screenshot::Os(diff/os.rb),Diff::Reporters::Default(diff/reporters/default.rb), the fourCapybaraScreenshotDifferror classes (capybara_screenshot_diff.rb).Class C — never mapped at all. Not in any
const_missingtable and not eagerly aliased anywhere a canonical require reaches:CapybaraScreenshotDiff::DSL,CapybaraScreenshotDiff::Minitest::Assertions,Drivers::ChunkyPNGDriver,Drivers::VipsDriver. (The two leaves could never reachconst_missingat all —SnapDiff::Driversis a real module, so a missing leaf on it is just aNameError.)Not defective (checked, symmetric):
BrowserHelpers,Screenshoter,Vcs,ImagePreprocessor,AnnotationService,Drivers,ImageCompare,Difference,Comparison,VERSION,LOADED_DRIVERS,AVAILABLE_DRIVERS,RED_RGBA,ORANGE_RGBA,SnapManager,Snap,ScreenshotNamer,BacktraceFilter,ErrorWithFilteredBacktrace,ScreenshotAssertion,AssertionRegistry, top-levelRegion.Fix — the class of bug, not the instance
Class A:
LegacyShims.resolvenow loads the replacement before naming it, deriving the file path from the gem's own convention (SnapDiff::AreaCalculator→snap_diff/area_calculator), with a one-entry override table for the odd one out. If it still cannot resolve, the error speaks the user's vocabulary:(It deliberately does not advise "reference
SnapDiff::Utilsdirectly" — we just failed to load it, so that name does not exist either.)Class B: the eager aliases moved into
snap_diff/legacy_shims.rb— the one file every entry point loads. That file is already in the reverse gate'sDELETED_IN_3_0set, so this adds no core→legacy edge.Class C:
DSLandMinitest::Assertionsare now mapped lazily. They cannot be eager inlegacy_shims(snap_diff/dslrequiressnap_diffback; the minitest integration pulls in the minitest gem, which no canonical entry should force), and they stay eager under the legacy entries. The driver leaves becameautoloadonSnapDiff::Drivers— which fixes the canonicalSnapDiff::Drivers::VipsDriverunder a bare require too.The forwarder files now only
require; duplicating the assignment would emitalready initialized constant(Ruby warns even for identical values).Guard.
legacy_entry_point_probe_test.rb→ "every legacy constant resolves under a canonical-only require": 38 constants × all 16 entry points, in subprocesses. Plus the vips-leaf test and "an unloadable legacy constant points at the upgrade guide". Same-object identity is not re-asserted here —namespace_forwarding_test.rbalready pins it.Mutation. Dropped the
Osalias and the lazy target load → guard fails on all 16 entry points.After: 0 broken across all seven canonical entries and all nine legacy ones.
Follow-up: the autoload broke v1 feature detection on a vips-less box
Caught in review.
ruby-vipsis not a runtime dependency, and the documented v1 pattern isDiff.driver = :vips if defined?(...Drivers::VipsDriver). An unconditionalautoloadmakes that truthy everywhere.Red (vips hidden from the load path):
v1.12.0 loaded
vips_driver.rbonly fromfind_driver_class_for, sodefined?wasnilthere. Fix: declare each leaf only when its gem is present, which is why the twoautoloadlines sit belowAVAILABLE_DRIVERS.Green:
Guard:
drivers_test.rb→ "an unavailable driver leaf stays undefined rather than autoloading into a crash" (subprocess,require "vips"forced toLoadError; also asserts the chunky_png leaf still autoloads). Mutation: dropping theif AVAILABLE_DRIVERS.include?(:vips)gate →VipsDriver is const_defined? without ruby-vips.Defect 3 — the deprecation strategy was silent for real usage
Reproduction. Exercising all 14 legacy APIs the real project's setup file touches (6
Capybara::Screenshot.*setters, 5Capybara::Screenshot::Diff.*setters,Os.name,DSL,Minitest::Assertions) under-wproduced zero warnings.Structural cause:
Deprecation.warnwas reachable only viaconst_missing, so it could never fire for an eagerly-aliased constant, and the generated config accessors were plain delegators with no deprecation path at all. Meanwhiledocs/snapdiff.mdclaimed legacy names work "with a one-time deprecation warning per constant". Net effect: a silent 2.x, then a bareLoadError/NameErrorat 3.0 with no pointer to SnapDiff.(a) The docs now tell the truth
docs/UPGRADING.mdgained a rewritten Deprecation Warnings section split into three parts — the once-per-process migration notice, the per-constant warnings (each shimmed constant listed by name), and Silent by design, which enumerates every silent name and why. It now also records thatDSL/Minitest::Assertionswarn only under a canonical require (under the v1 entries — what an unmigrated app actually uses — they are eager and silent), that touching a driver leaf through the old path still warns once for...::Drivers, and that leafdefined?staysnilwithout the gem.docs/snapdiff.mdandREADME.mdno longer claim every legacy name warns.(b) One migration notice per process
Fires from every door that can be hooked, then never again:
install_config_accessorsDeprecation.warn, ahead of the per-constant lineinclude Capybara::Screenshot[::Diff]install_include_notice→includedhookDiff.default_optionsReuses the existing switch (
SnapDiff.silence_deprecations/SNAP_DIFF_SILENCE_DEPRECATIONS). Lock-free fast path after the first call.Scoping the gem's own suite.
test_helper.rbalready raises on any[snap_diff deprecation]output, and the suite configures throughCapybara::Screenshot.root=by design — so it callsDeprecation.suppress_migration_notice!, which silences only the notice and deliberately survivesreset!. The per-constant raise-on-deprecation guard is untouched and still fires.Guards (
test/legacy/snap_diff_deprecation_test.rb, all subprocess — "once per process" is the contract): one test per door, each asserting exactly one notice on its own; one asserting exactly once across all doors × 3 iterations, containingdocs/UPGRADING.md,REMOVED in 3.0,SNAP_DIFF_SILENCE_DEPRECATIONS; one asserting it does not fire for a purely canonical setup; two for the silencing switches.Mutations — each firing point and each property broken independently:
Deprecation.noticefrom the config delegatorsincludedhookdefault_optionssilence_deprecations?checknoticeatlegacy_shimsload (mis-scoped)The first mutation is worth calling out: my initial combined guard passed under it, because the
const_missingdoor was still firing and masking the config-delegator regression. Splitting one test per door is what made it fail.🤖 Generated with Claude Code