Bare CSTTransformer metadata pass poisons the inherited-dependency cache for all later subclasses
- Dominant language
- Python
- Stars
- 1.9k
- Forks
- 229
- PR merge metrics
- No merged PRs in 30d
Description
## Summary
`MetadataDependent.get_inherited_dependencies` memoizes into a plain class attribute and reads it back with ordinary attribute lookup. Because that lookup walks the MRO, running a metadata pass with a **bare** `CSTTransformer`/`CSTVisitor` caches an empty `frozenset` onto the base class, and every subclass that has not yet computed its own cache then inherits that empty set — raising `METADATA_DEPENDENCIES` `KeyError`s even though the subclass declares the provider correctly.
The poisoning is global and permanent for the process, and it surfaces arbitrarily far from the cause.
## Reproduction
```python
import libcst as cst
from libcst.metadata import MetadataWrapper, PositionProvider
class PositionAware(cst.CSTTransformer):
METADATA_DEPENDENCIES = (PositionProvider,)
def leave_Pass(self, original_node, updated_node):
self.get_metadata(PositionProvider, original_node)
return updated_node
def run(visitor):
MetadataWrapper(cst.parse_module("pass\n")).visit(visitor)
run(PositionAware()) # fine
run(cst.CSTTransformer()) # <-- poisons the base class
run(PositionAware()) # KeyError
```
```
KeyError: 'PositionProvider is not declared as a dependency in PositionAware.METADATA_DEPENDENCIES.'
```
Observed on libcst 1.8.6 (current release) with CPython 3.14.6; the same code is present on `main`.
## Cause
```python
try:
return cls._INHERITED_METADATA_DEPENDENCIES_CACHE
except AttributeError:
...
cls._INHERITED_METADATA_DEPENDENCIES_CACHE = frozenset(dependencies)
return cls._INHERITED_METADATA_DEPENDENCIES_CACHE
```
`cls._INHERITED_METADATA_DEPENDENCIES_CACHE` resolves through the MRO, so the `try` succeeds against a *base class* entry. Resolving `CSTTransformer` itself writes `frozenset()` there, since the base declares no dependencies. Every later subclass without its own entry reads it.
Note the trigger is **resolution**, not construction — `cst.CSTTransformer()` alone is harmless; the cache is only written when `get_inherited_dependencies()` runs during a metadata pass.
## Impact
This is hard to diagnose in a test suite. One helper that ran a bare transformer broke 38 tests across five unrelated files in our codebase, and bisecting by file or by single test finds nothing, because the poison needs one specific test to have run first and is then permanent for the interpreter. The reported error also points at the innocent subclass, which reads as a bug in that class rather than in shared state.
## Suggested fix
Read only the class's own entry, so a base-class cache can never be inherited:
```python
@classmethod
def get_inherited_dependencies(cls) -> Collection["ProviderT"]:
cache = cls.__dict__.get("_INHERITED_METADATA_DEPENDENCIES_CACHE")
if cache is None:
dependencies = set()
for c in inspect.getmro(cls):
if issubclass(c, MetadataDependent):
dependencies.update(c.METADATA_DEPENDENCIES)
cache = frozenset(dependencies)
cls._INHERITED_METADATA_DEPENDENCIES_CACHE = cache
return cache
```
This keeps the memoization and its per-class semantics, and costs one dict lookup. Happy to send this as a PR if the approach looks right.
Contributor guide
Research direction
Start at MetadataDependent.get_inherited_dependencies and reproduce the three-visitor sequence from the issue, including the bare CSTTransformer pass. Verify that cache lookup is limited to the current class, then add a regression test showing PositionAware still resolves PositionProvider afterward and run the relevant metadata tests.
Written by the indexing model from the issue text.
Assessment
- Tech stack
- python
- Domain
- tooling
- Issue type
- Bug
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Activity status
- Quiet
- Clarity
- Clearly specified
- Newbie friendliness
- 78/100