Skip to content

Key anonymous fixture caches on the declaration site - #81

Merged
ngan merged 1 commit into
mainfrom
np-fix-anonymous-fixture-name-collision
Aug 20, 2026
Merged

Key anonymous fixture caches on the declaration site#81
ngan merged 1 commit into
mainfrom
np-fix-anonymous-fixture-name-collision

Conversation

@ngan

@ngan ngan commented Aug 20, 2026

Copy link
Copy Markdown
Collaborator

Anonymous fixtures — the inline fixture do … end / fixture extends: "x" do … end form — 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's generate is a no-op and its mount returns 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 in disambiguate by appending a counter:

def self.disambiguate(name, const_scope)
  return name unless const_defined_on?(const_scope, name)
  name << "_2"
  name.next! while const_defined_on?(const_scope, name)
  name
end

Two problems, and the second is the one that bites:

(a) base_name_for is lossy. Foo::Bar, "Foo Bar" and "Foo-Bar" all collapse to FooBar.

(b) The disambiguation counter resets mid-process. It only appends a suffix while the earlier constant still exists, and RSpec::Core::World#reset starts with RSpec::ExampleGroups.remove_all_constants. Distributed and batched test runners call world.reset before loading each batch, so the first group of a given description in every batch gets the unsuffixed name.

Demonstrated with nothing but rspec-core:

--- both groups declared in the SAME batch ---
group 1 -> some_description
group 2 -> some_description_2   # RSpec disambiguated
same cache file? false

--- each group declared in its OWN batch (world.reset between) ---
batch 0 -> some_description
batch 1 -> some_description     # no suffix this time
same cache file? true

Why that becomes wrong data

The cache directory is cleared once per process (Runner#start, from before(:suite)), and Fixture#finish drops only the in-memory copy for anonymous fixtures — the JSON file outlives the batch that wrote it. So Cache#exists? reports another declaration's file as "already generated" and Fixture#generate returns early:

def generate(force: false)
  return if @cache.exists? && !force

Cache#load then builds the repository from that file, and Repository defines its readers from whatever names it finds:

NoMethodError: undefined method '<name>' for #<FixtureKit::Repository:0x…>

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:

_anonymous/foo/with_fixture_kit/hello.3f2a9c1d4b8e.json

Definition#fingerprint is the block's source_location plus its extends: target. source_location uniquely identifies each fixture 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. extends is included because a helper that declares fixture(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_saved logs 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" becomes abc).

identifier_for is untouched, so adapter subclasses keep working, and MinitestAdapter is covered too since the digest is applied in Cache rather than in either adapter.

Also adds Registry#claim_cache_identifier, called from Fixture#generate, so any future collision raises and names both declaration sites rather than silently mounting the wrong records:

FixtureKit::CacheIdentifierCollision:
  fixtures declared at …/batch_three_collision.rb:10 and …/batch_four_collision.rb:7
  both resolve to the cache identifier '_anonymous/queue_mode_colliding_description'

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 NoMethodError above; it now passes.

I verified the test actually gates on the fix by reverting only the identifier change — with the digest gone, the new CacheIdentifierCollision fires 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 the rspec and minitest integration paths.

Upgrade note

Cache naming changes, so existing tmp/cache/fixture_kit contents 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

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
@ngan
ngan merged commit a66ace2 into main Aug 20, 2026
16 checks passed
@ngan
ngan deleted the np-fix-anonymous-fixture-name-collision branch August 20, 2026 04:56
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant