Module refinery.lib.scripts.modelcache

Shared machinery for the per-run analysis model caches. A language builds one cache over the script being transformed and shares it across every transform in a run, rebuilding its models only after that script's tree changes — whether a transform announces the change through Transformer.changed or an in-pass mutation advances the script's tree_version() counter — instead of each transform rebuilding from scratch on every pass.

ModelCache and Ps1ModelCache are the concrete caches: each subclass declares its typed model slots in _SLOTS and exposes one lazy property per model built through _lazy, and inherits the version tracking, edge-triggered invalidation, the ModelCacheBase.pinned() suspension of that invalidation, and the transformer-reuse stash from here. Keeping the mechanism in one place is why a new language's cache cannot drift from the invalidation contract the base establishes.

Expand source code Browse git
"""
Shared machinery for the per-run analysis model caches. A language builds one cache over the script
being transformed and shares it across every transform in a run, rebuilding its models only after
that script's tree changes — whether a transform announces the change through
`refinery.lib.scripts.Transformer.changed` or an in-pass mutation advances the script's
`refinery.lib.scripts.tree_version` counter — instead of each transform rebuilding from scratch on
every pass.

`refinery.lib.scripts.js.analysis.cache.ModelCache` and
`refinery.lib.scripts.ps1.analysis.cache.Ps1ModelCache` are the concrete caches: each subclass
declares its typed model slots in `_SLOTS` and exposes one lazy property per model built through
`_lazy`, and inherits the version tracking, edge-triggered invalidation, the `ModelCacheBase.pinned`
suspension of that invalidation, and the transformer-reuse stash from here. Keeping the mechanism in
one place is why a new language's cache cannot drift from the invalidation contract the base
establishes.
"""
from __future__ import annotations

from contextlib import contextmanager
from typing import Callable, Generator, TypeVar

from refinery.lib.scripts import Node, Transformer, tree_root, tree_version

_T = TypeVar('_T')
_C = TypeVar('_C', bound='ModelCacheBase')


class ModelCacheBase:
    """
    The version-tracking, invalidation, and reuse mechanism shared by every language's model cache.
    A subclass lists its lazily-built model attributes in `_SLOTS`, reads each through `_lazy`, and
    re-declares `root` at the node type it builds its models from. The base nulls the slots on
    construction, drops them together whenever this root's AST-mutation counter
    (`refinery.lib.scripts.tree_version`) advances past the value they were built at, and rebuilds
    on next access. Dropping the models together keeps a derived model consistent with the base
    model it was layered on. Because the base owns the whole mechanism, `invalidate` — the one
    method the `refinery.lib.scripts.AnalysisCache` protocol requires — is defined once, not per
    language. `pinned` suspends that mechanism for the length of a block, and lives here for the
    same reason: it is the one thing that can hold a model across a mutation, so a language cache
    cannot be allowed to grow its own version of it.

    The run's caller-supplied `options` are held here rather than per language, because a model
    built differently under them is built once per cache and `for_transformer` is the one place a
    cache is created without a caller naming them. Taking them off the transformer there is what
    keeps the two from disagreeing: the pipeline sets the same object on both, and a cache that
    defaulted its own would answer under a configuration the transformer beside it does not hold.
    """

    _SLOTS: tuple[str, ...] = ()

    # A class attribute rather than an assignment in `__init__`, because `__init__` calls
    # `invalidate`, which reads this: an instance attribute would not exist yet at that point.
    _pins = 0

    root: Node
    options: object | None

    def __init__(self, root: Node, options: object | None = None):
        # Normalized here and not only in `for_transformer`, because the version counter a mutation
        # advances is the one keyed on the tree: a cache holding a nested node as its root would
        # read a counter nothing ever bumps and never invalidate.
        root = tree_root(root)
        self.root = root
        self.options = options
        self._version = tree_version(root)
        self.invalidate()

    def invalidate(self) -> None:
        if self._pins:
            return
        for slot in self._SLOTS:
            setattr(self, slot, None)

    @contextmanager
    def pinned(self: _C) -> Generator[_C, None, None]:
        """
        Hold the models for the duration of the block: each is still built on first use, and
        afterwards the memoized instance is served even as the tree changes underneath it. On exit
        the pin is released and the models are dropped, so no stale model outlives the block.

        This exists because a transform that both rewrites the tree and consults a model on every
        rewrite otherwise rebuilds the model per rewrite — the cost is the product of the two, and
        it dominated deobfuscation runtime. Suppression is counted so that an inner pin cannot
        release an outer one.

        **The caller must know that its own rewrites cannot make the models it reads more
        permissive.** That is a property of the specific transform, not of pinning: a pass that
        could reveal a fact its held model predates would act on the stale, more permissive answer.
        A pass whose rewrites only ever make facts *more* restrictive is safe, because it then
        declines where it could have proceeded.
        """
        self._ensure_fresh()
        self._pins += 1
        try:
            yield self
        finally:
            self._pins -= 1
            if not self._pins:
                self._version = tree_version(self.root)
                self.invalidate()

    def _ensure_fresh(self) -> None:
        version = tree_version(self.root)
        if version != self._version:
            self._version = version
            self.invalidate()

    def _lazy(self, slot: str, build: Callable[[], _T]) -> _T:
        """
        The value memoized in *slot*, built through *build* on first access after construction or
        an invalidation. Every model property routes through here so freshness is checked and the
        slot is filled by the one accessor primitive rather than a hand-copied check-build-store
        per model.
        """
        self._ensure_fresh()
        value = getattr(self, slot)
        if value is None:
            value = build()
            setattr(self, slot, value)
        return value

    @classmethod
    def for_transformer(cls: type[_C], transformer: Transformer, root: Node) -> _C:
        """
        The pipeline's shared cache for *root* when one of this exact class is attached to
        *transformer* and built over that same root, otherwise a fresh cache — stashed back onto
        *transformer* so later lookups within its single-pass lifetime reuse it instead of
        rebuilding the models per call. A transform still runs standalone (in tests, or outside the
        pipeline); freshness stays governed by the tree version, and a standalone mutation
        invalidates the stashed cache exactly as it would the shared one.

        *root* is normalized to the tree it belongs to — by the constructor, so the two entry points
        cannot disagree — and a transform that visits a nested body therefore means the same cache
        as one that visits the script. Skipping that leaves a whole-script model derived from a
        subtree: a leak sitting outside it becomes invisible and the world reads closed, which is
        the one direction that deletes code.

        A fresh cache takes the options the transformer is holding, which is the run's own
        configuration wherever the pipeline set it. Defaulting them here instead would let one
        transform in a run read a model built under a configuration the run never asked for, and
        stash that cache back for every transform after it.
        """
        root = tree_root(root)
        cache = transformer.models
        if isinstance(cache, cls) and cache.root is root:
            return cache
        cache = cls(root, transformer.options)
        transformer.models = cache
        return cache

Classes

class ModelCacheBase (root, options=None)

The version-tracking, invalidation, and reuse mechanism shared by every language's model cache. A subclass lists its lazily-built model attributes in _SLOTS, reads each through _lazy, and re-declares root at the node type it builds its models from. The base nulls the slots on construction, drops them together whenever this root's AST-mutation counter (tree_version()) advances past the value they were built at, and rebuilds on next access. Dropping the models together keeps a derived model consistent with the base model it was layered on. Because the base owns the whole mechanism, invalidate — the one method the AnalysisCache protocol requires — is defined once, not per language. pinned suspends that mechanism for the length of a block, and lives here for the same reason: it is the one thing that can hold a model across a mutation, so a language cache cannot be allowed to grow its own version of it.

The run's caller-supplied options are held here rather than per language, because a model built differently under them is built once per cache and for_transformer is the one place a cache is created without a caller naming them. Taking them off the transformer there is what keeps the two from disagreeing: the pipeline sets the same object on both, and a cache that defaulted its own would answer under a configuration the transformer beside it does not hold.

Expand source code Browse git
class ModelCacheBase:
    """
    The version-tracking, invalidation, and reuse mechanism shared by every language's model cache.
    A subclass lists its lazily-built model attributes in `_SLOTS`, reads each through `_lazy`, and
    re-declares `root` at the node type it builds its models from. The base nulls the slots on
    construction, drops them together whenever this root's AST-mutation counter
    (`refinery.lib.scripts.tree_version`) advances past the value they were built at, and rebuilds
    on next access. Dropping the models together keeps a derived model consistent with the base
    model it was layered on. Because the base owns the whole mechanism, `invalidate` — the one
    method the `refinery.lib.scripts.AnalysisCache` protocol requires — is defined once, not per
    language. `pinned` suspends that mechanism for the length of a block, and lives here for the
    same reason: it is the one thing that can hold a model across a mutation, so a language cache
    cannot be allowed to grow its own version of it.

    The run's caller-supplied `options` are held here rather than per language, because a model
    built differently under them is built once per cache and `for_transformer` is the one place a
    cache is created without a caller naming them. Taking them off the transformer there is what
    keeps the two from disagreeing: the pipeline sets the same object on both, and a cache that
    defaulted its own would answer under a configuration the transformer beside it does not hold.
    """

    _SLOTS: tuple[str, ...] = ()

    # A class attribute rather than an assignment in `__init__`, because `__init__` calls
    # `invalidate`, which reads this: an instance attribute would not exist yet at that point.
    _pins = 0

    root: Node
    options: object | None

    def __init__(self, root: Node, options: object | None = None):
        # Normalized here and not only in `for_transformer`, because the version counter a mutation
        # advances is the one keyed on the tree: a cache holding a nested node as its root would
        # read a counter nothing ever bumps and never invalidate.
        root = tree_root(root)
        self.root = root
        self.options = options
        self._version = tree_version(root)
        self.invalidate()

    def invalidate(self) -> None:
        if self._pins:
            return
        for slot in self._SLOTS:
            setattr(self, slot, None)

    @contextmanager
    def pinned(self: _C) -> Generator[_C, None, None]:
        """
        Hold the models for the duration of the block: each is still built on first use, and
        afterwards the memoized instance is served even as the tree changes underneath it. On exit
        the pin is released and the models are dropped, so no stale model outlives the block.

        This exists because a transform that both rewrites the tree and consults a model on every
        rewrite otherwise rebuilds the model per rewrite — the cost is the product of the two, and
        it dominated deobfuscation runtime. Suppression is counted so that an inner pin cannot
        release an outer one.

        **The caller must know that its own rewrites cannot make the models it reads more
        permissive.** That is a property of the specific transform, not of pinning: a pass that
        could reveal a fact its held model predates would act on the stale, more permissive answer.
        A pass whose rewrites only ever make facts *more* restrictive is safe, because it then
        declines where it could have proceeded.
        """
        self._ensure_fresh()
        self._pins += 1
        try:
            yield self
        finally:
            self._pins -= 1
            if not self._pins:
                self._version = tree_version(self.root)
                self.invalidate()

    def _ensure_fresh(self) -> None:
        version = tree_version(self.root)
        if version != self._version:
            self._version = version
            self.invalidate()

    def _lazy(self, slot: str, build: Callable[[], _T]) -> _T:
        """
        The value memoized in *slot*, built through *build* on first access after construction or
        an invalidation. Every model property routes through here so freshness is checked and the
        slot is filled by the one accessor primitive rather than a hand-copied check-build-store
        per model.
        """
        self._ensure_fresh()
        value = getattr(self, slot)
        if value is None:
            value = build()
            setattr(self, slot, value)
        return value

    @classmethod
    def for_transformer(cls: type[_C], transformer: Transformer, root: Node) -> _C:
        """
        The pipeline's shared cache for *root* when one of this exact class is attached to
        *transformer* and built over that same root, otherwise a fresh cache — stashed back onto
        *transformer* so later lookups within its single-pass lifetime reuse it instead of
        rebuilding the models per call. A transform still runs standalone (in tests, or outside the
        pipeline); freshness stays governed by the tree version, and a standalone mutation
        invalidates the stashed cache exactly as it would the shared one.

        *root* is normalized to the tree it belongs to — by the constructor, so the two entry points
        cannot disagree — and a transform that visits a nested body therefore means the same cache
        as one that visits the script. Skipping that leaves a whole-script model derived from a
        subtree: a leak sitting outside it becomes invisible and the world reads closed, which is
        the one direction that deletes code.

        A fresh cache takes the options the transformer is holding, which is the run's own
        configuration wherever the pipeline set it. Defaulting them here instead would let one
        transform in a run read a model built under a configuration the run never asked for, and
        stash that cache back for every transform after it.
        """
        root = tree_root(root)
        cache = transformer.models
        if isinstance(cache, cls) and cache.root is root:
            return cache
        cache = cls(root, transformer.options)
        transformer.models = cache
        return cache

Subclasses

Class variables

var root

The type of the None singleton.

var options

The type of the None singleton.

Static methods

def for_transformer(transformer, root)

The pipeline's shared cache for root when one of this exact class is attached to transformer and built over that same root, otherwise a fresh cache — stashed back onto transformer so later lookups within its single-pass lifetime reuse it instead of rebuilding the models per call. A transform still runs standalone (in tests, or outside the pipeline); freshness stays governed by the tree version, and a standalone mutation invalidates the stashed cache exactly as it would the shared one.

root is normalized to the tree it belongs to — by the constructor, so the two entry points cannot disagree — and a transform that visits a nested body therefore means the same cache as one that visits the script. Skipping that leaves a whole-script model derived from a subtree: a leak sitting outside it becomes invisible and the world reads closed, which is the one direction that deletes code.

A fresh cache takes the options the transformer is holding, which is the run's own configuration wherever the pipeline set it. Defaulting them here instead would let one transform in a run read a model built under a configuration the run never asked for, and stash that cache back for every transform after it.

Methods

def invalidate(self)
Expand source code Browse git
def invalidate(self) -> None:
    if self._pins:
        return
    for slot in self._SLOTS:
        setattr(self, slot, None)
def pinned(self)

Hold the models for the duration of the block: each is still built on first use, and afterwards the memoized instance is served even as the tree changes underneath it. On exit the pin is released and the models are dropped, so no stale model outlives the block.

This exists because a transform that both rewrites the tree and consults a model on every rewrite otherwise rebuilds the model per rewrite — the cost is the product of the two, and it dominated deobfuscation runtime. Suppression is counted so that an inner pin cannot release an outer one.

The caller must know that its own rewrites cannot make the models it reads more permissive. That is a property of the specific transform, not of pinning: a pass that could reveal a fact its held model predates would act on the stale, more permissive answer. A pass whose rewrites only ever make facts more restrictive is safe, because it then declines where it could have proceeded.

Expand source code Browse git
@contextmanager
def pinned(self: _C) -> Generator[_C, None, None]:
    """
    Hold the models for the duration of the block: each is still built on first use, and
    afterwards the memoized instance is served even as the tree changes underneath it. On exit
    the pin is released and the models are dropped, so no stale model outlives the block.

    This exists because a transform that both rewrites the tree and consults a model on every
    rewrite otherwise rebuilds the model per rewrite — the cost is the product of the two, and
    it dominated deobfuscation runtime. Suppression is counted so that an inner pin cannot
    release an outer one.

    **The caller must know that its own rewrites cannot make the models it reads more
    permissive.** That is a property of the specific transform, not of pinning: a pass that
    could reveal a fact its held model predates would act on the stale, more permissive answer.
    A pass whose rewrites only ever make facts *more* restrictive is safe, because it then
    declines where it could have proceeded.
    """
    self._ensure_fresh()
    self._pins += 1
    try:
        yield self
    finally:
        self._pins -= 1
        if not self._pins:
            self._version = tree_version(self.root)
            self.invalidate()