Key anonymous fixture caches on the declaration site - #81
Merged
Conversation
Anonymous fixtures are cached on disk under a filename derived from the
example group's constant name, which is not a unique identifier for a
declaration site.
RSpec builds that constant from the group description via `base_name_for`,
which strips every non-alphanumeric character, and resolves duplicates in
`disambiguate` by appending a counter -- but only while the earlier constant
still exists. `RSpec::Core::World#reset` calls
`RSpec::ExampleGroups.remove_all_constants`, so test runners that load specs
in batches and reset the world between them hand the first group of a given
description in *every* batch the same unsuffixed name.
The cache directory is cleared once per process, in `Runner#start`, and
`Fixture#finish` drops only the in-memory copy for anonymous fixtures. So the
JSON file outlives the batch that wrote it, `Cache#exists?` reports it as
already generated, and `Fixture#generate` returns early. The second fixture
then mounts the first one's exposed records, which surfaces as
NoMethodError: undefined method '<name>' for #<FixtureKit::Repository>
or, when both definitions expose the same names, does not surface at all --
the suite passes against the wrong records.
Fold a digest of the declaration site into the anonymous identifier:
_anonymous/foo/with_fixture_kit/hello.3f2a9c1d4b8e.json
`Definition#fingerprint` is the block's `source_location` plus its `extends:`
target, which is unique per declaration, stable across processes, and
independent of the test framework's naming. `extends` is included because a
helper that declares `fixture(extends: ...)` gives every caller the same file
and line, and sharing a cache entry there would mount the wrong parent.
The digest is joined with a dot rather than an underscore: scope names are
snake_case, so an underscore leaves no boundary between the two, while a dot
cannot appear in one.
`identifier_for` is untouched, so adapter subclasses keep working, and the
Minitest adapter is covered too since the digest is applied in `Cache`.
Also add `Registry#claim_cache_identifier`, called from `Fixture#generate`, so
that any future collision raises and names both declaration sites instead of
silently mounting the wrong records. It deliberately allows two fixtures built
from the same declaration to share an entry, which is the shared-example-group
case.
Cache naming changes, so existing `tmp/cache/fixture_kit` contents are
ignored. They regenerate; expect a one-time cost on the first run after this.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01XgYUaNMUsHSWNn1HEwqgrT
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.
Anonymous fixtures — the inline
fixture do … end/fixture extends: "x" do … endform — are cached on disk under a filename derived from the RSpec example-group constant name. That name is not a unique identifier for a declaration site, so two spec files can claim the same cache entry. When they do, the second fixture'sgenerateis a no-op and itsmountreturns the first fixture's exposed records.Why the identifier isn't unique
RSpec builds the group constant from the description via
ExampleGroup.base_name_for, which strips every non-alphanumeric character, and resolves duplicates indisambiguateby appending a counter:Two problems, and the second is the one that bites:
(a)
base_name_foris lossy.Foo::Bar,"Foo Bar"and"Foo-Bar"all collapse toFooBar.(b) The disambiguation counter resets mid-process. It only appends a suffix while the earlier constant still exists, and
RSpec::Core::World#resetstarts withRSpec::ExampleGroups.remove_all_constants. Distributed and batched test runners callworld.resetbefore loading each batch, so the first group of a given description in every batch gets the unsuffixed name.Demonstrated with nothing but
rspec-core:Why that becomes wrong data
The cache directory is cleared once per process (
Runner#start, frombefore(:suite)), andFixture#finishdrops only the in-memory copy for anonymous fixtures — the JSON file outlives the batch that wrote it. SoCache#exists?reports another declaration's file as "already generated" andFixture#generatereturns early:Cache#loadthen builds the repository from that file, andRepositorydefines its readers from whatever names it finds:The worse case is when it doesn't raise. If both definitions happen to expose the same names, the readers exist, the suite passes — against the wrong records. That silent case is the real reason to fix this rather than work around the flake.
The fix
Fold a digest of the declaration site into the anonymous identifier:
Definition#fingerprintis the block'ssource_locationplus itsextends:target.source_locationuniquely identifies eachfixture do … end, is stable across processes, and doesn't depend on the test framework's constant naming at all — the information was already there, just unused.extendsis included because a helper that declaresfixture(extends: some_variable)gives every caller the same file and line, and sharing a cache entry there would mount the wrong parent's records.The readable slug stays as a prefix so cache files and
on_cache_savedlogs remain browsable. The digest is joined with a dot rather than an underscore: scope names are snake_case, so an underscore leaves no parseable boundary, while a dot cannot appear in a slug (RSpec strips non-alphanumerics from the description before it is underscored, so even"a.b.c"becomesabc).identifier_foris untouched, so adapter subclasses keep working, andMinitestAdapteris covered too since the digest is applied inCacherather than in either adapter.Also adds
Registry#claim_cache_identifier, called fromFixture#generate, so any future collision raises and names both declaration sites rather than silently mounting the wrong records:It deliberately allows two fixtures built from the same declaration to share an entry — that's the shared-example-group case, where two host groups legitimately share one definition and raising would break working suites.
Testing
The failure is reproduced as a regression test rather than asserted: the existing queue-mode simulation gets two more batches whose top-level descriptions collide, each declaring a different anonymous fixture. Against the unfixed gem that produced the
NoMethodErrorabove; it now passes.I verified the test actually gates on the fix by reverting only the identifier change — with the digest gone, the new
CacheIdentifierCollisionfires instead, which also confirms the backstop works end to end.Unit coverage added for
Definition#location/#fingerprint, for anonymous identifiers whose scopes stringify identically, for definitions extending different parents, and for the registry guard.209 examples, 0 failures, on both therspecandminitestintegration paths.Upgrade note
Cache naming changes, so existing
tmp/cache/fixture_kitcontents are ignored. Harmless — they regenerate — but expect a one-time cost on the first run after the bump.🤖 Generated with Claude Code
https://claude.ai/code/session_01XgYUaNMUsHSWNn1HEwqgrT