Skip to content

Bare CSTTransformer metadata pass poisons the inherited-dependency cache for all later subclasses #1459

Description

@tarrific

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 KeyErrors 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

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

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:

@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.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions