• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

KarlNaumann / MacroStat / 27136632269

08 Jun 2026 12:09PM UTC coverage: 91.115% (+0.3%) from 90.805%
27136632269

push

github

web-flow
feat(pichler): production canonical (#82)

* feat(causality): add MethodSpec foundation library

Port EIRINpy v0.11.0's MethodSpec system into MacroStat as a single
import-pure stdlib module. Two decorators (writes / requires) attach a
frozen MethodSpec to each behavior method declaring per-buffer literal
key sets plus a DYNAMIC sentinel for computed-key accesses. Zero
per-call cost: decorators set func.__method_spec__ and return the
function unwrapped.

lint_class walks the call graph from a chosen root, runs an AST
visitor over each reachable method, and emits one DriftEntry per
method classifying declared vs observed accesses (OK,
MISSING_DECORATOR, DRIFT, INDIRECT_WRITE, DYNAMIC_UNRESOLVED). The
visitor recognises self.<buffer>[K] subscripts on state / prior /
history / params / scenarios / hyper plus the scenario and params
kwarg-name magic guarded by the enclosing FunctionDef signature.
AugAssign targets are counted as both read and write via a one-pass
pre-walk. Mutator-call indirection is resolved through a public
_STATE_MUTATORS registry populated by register_mutator (idempotent,
raises on conflict).

R6d (check_non_buffer_attrs) enforces that subclasses of Behavior
only read tracked buffers, underscore-private caches, or names
already bound in their own __init__; writes outside __init__ and
initialize to non-buffer non-private attrs are banned regardless of
init binding (variant b — closes the canary-binding evasion). The
allowlist is auto-derived from each class's __init__ AST per the plan,
with a per-class cache.

First of five dispatches splitting the superseded
MethodSpecDecorators plan. No model retrofit in this dispatch;
downstream dispatches consume the stable library API.

* test(causality): cover MethodSpec foundation

Lock the public surface of writes/requires, lint_class, the mutator
registry, and the non-buffer attribute check so later edits and
model rollouts cannot break it without a red test. Fixtures sit at
module level becau... (continued)

601 of 642 branches covered (93.61%)

Branch coverage included in aggregate %.

340 of 367 new or added lines in 2 files covered. (92.64%)

3778 of 4164 relevant lines covered (90.73%)

5.44 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

94.07
/src/macrostat/causality/method_spec.py
1
# SPDX-License-Identifier: MIT
2
# Copyright (c) 2026 Karl Naumann-Woleske
3
r"""Per-method read/write metadata, AST drift lint, and R6d attribute rule.
4

5
The :func:`writes` and :func:`requires` decorators attach a :class:`MethodSpec`
6
to a behavior method so the read/write graph over a call-graph root is
7
machine-readable. :func:`lint_class` walks the AST of every reachable method
8
and emits a :class:`DriftEntry` per non-trivial method classifying whether the
9
declared spec matches the observed accesses.
10

11
The module is import-pure: only stdlib (``ast``, ``dataclasses``, ``enum``,
12
``inspect``, ``textwrap``). No pandas, no NetworkX. Every model in MacroStat
13
can import :func:`writes` / :func:`requires` without dragging analyzer deps.
14

15
Decorator API
16
-------------
17

18
Two decorators expose a uniform surface keyed by buffer name:
19

20
* ``@writes(state=("X","Y"))`` declares a method writes those keys via
21
  ``self.state[K] = ...``. Accepts ``state``, ``prior``, ``history`` kwargs.
22
* ``@requires(state=("Z",), params=("alpha",))`` declares the reads.
23
  Accepts ``state``, ``prior``, ``history``, ``params``, ``scenario``,
24
  ``hyper`` kwargs.
25

26
The :data:`DYNAMIC` sentinel (``"*"``) declares an unresolved-key access:
27
``@requires(state=("X", DYNAMIC))`` says the method reads ``self.state["X"]``
28
plus at least one computed-key state slot the walker cannot resolve.
29

30
Decorators set ``func.__method_spec__`` only — no wrapping closure, zero
31
per-call cost. Stacking via :func:`dataclasses.replace` merges new keys into
32
the existing spec (insertion-order preserving via :func:`dict.fromkeys`).
33

34
Walker semantics
35
----------------
36

37
:class:`_AccessVisitor` recognises:
38

39
* ``self.state[K]`` / ``self.prior[K]`` / ``self.history[K]`` / ``self.params[K]``
40
  / ``self.scenarios[K]`` / ``self.hyper[K]`` — buffer reads and writes.
41
* ``scenario[K]`` — recognised only when the enclosing ``FunctionDef`` has
42
  ``scenario`` as a positional argument (matches the kwarg passed by
43
  ``Behavior.forward``).
44
* ``params[K]`` — recognised only when the enclosing ``FunctionDef`` has
45
  ``params`` as a positional argument.
46
* ``self.state[K] += ...`` — counted as both read and write via a one-pass
47
  pre-walk over :class:`ast.AugAssign` targets.
48
* ``self.<mutator>(K, ...)`` where ``<mutator>`` is in :data:`_STATE_MUTATORS`
49
  — caller credited with the mutator's declared writes; the ``indirect``
50
  flag is set so :func:`lint_class` can flip ``DRIFT`` to ``INDIRECT_WRITE``
51
  when the only difference is the indirect contribution.
52
* ``<buffer>.get(K, default)`` — recognised as a read on the matching
53
  buffer. A literal ``K`` is added to the buffer's frozenset; a non-literal
54
  ``K`` inserts the :data:`DYNAMIC` sentinel.
55
* ``<buffer>.items()`` / ``.values()`` / ``.keys()`` — iteration over all
56
  keys; inserts :data:`DYNAMIC` into the matching buffer's frozenset.
57

58
Unrecognised slice shapes (tuple, walrus, starred-LHS, JoinedStr, computed
59
expression) insert the :data:`DYNAMIC` sentinel directly into the matching
60
buffer's frozenset — no parallel bool channel.
61

62
Limitations (documented):
63

64
* ``super().method()`` reads are invisible to the AST walker; the parent's
65
  spec does not transitively credit the child. The child must redeclare or
66
  the lint surfaces ``MISSING_DECORATOR`` on a covering frozenset of the
67
  child override.
68
* ``inspect.getsource`` failures on lambdas, exec-defined functions, and
69
  runtime-bound staticmethods raise normally; no silent OK.
70
* ``INDIRECT_WRITE`` / ``DYNAMIC_UNRESOLVED`` are advisory: the walker
71
  cannot prove the unresolved key set; the declared spec stands as authored
72
  intent.
73

74
R6d rule
75
--------
76

77
:func:`check_non_buffer_attrs` enforces that subclasses of ``Behavior`` only
78
use tracked buffer attributes, underscore-private caches, or names bound in
79
``__init__`` for reads in step methods. Writes outside ``__init__`` and
80
``initialize`` to non-buffer non-private names are always banned regardless
81
of init binding (variant b — closes the canary-binding evasion). The
82
allowlist is auto-derived from each class's own ``__init__`` AST; no
83
manually maintained dict.
84

85
Divergences from EIRINpy reference
86
----------------------------------
87

88
* ``requires_prior`` (singular) port-time API fix vs EIRINpy's
89
  ``requires_priors`` (plural). Reflects that ``self.prior`` is the buffer
90
  name in MacroStat.
91
* Two decorators (``writes`` / ``requires``) replace the 7-decorator EIRINpy
92
  surface. Functionality preserved; surface is uniform.
93
* ``DYNAMIC`` lives inside each buffer's frozenset rather than on parallel
94
  ``has_dynamic_*`` bool flags. Predicate ``DYNAMIC in spec.<field>``.
95
* :class:`DriftEntry` is a flat list; no :class:`DriftTable` wrapper.
96
* ``slots=True`` on :class:`MethodSpec` / :class:`DriftEntry` diverges from
97
  the ``core/constraints.py:42`` frozen-without-slots precedent. Separate
98
  one-line PR flagged to add slots to ``constraints.py``.
99
"""
100

101
from __future__ import annotations
6✔
102

103
import ast
6✔
104
import dataclasses
6✔
105
import inspect
6✔
106
import textwrap
6✔
107
from collections.abc import Callable
6✔
108
from dataclasses import dataclass
6✔
109
from enum import Enum
6✔
110

111
DYNAMIC: str = "*"
6✔
112
r"""Sentinel string declaring a computed (non-literal) key. Inserted into the
6✔
113
relevant buffer's frozenset by the walker; passed by the user to either
114
decorator to declare an intentional dynamic access."""
115

116
_WRITE_BUFFERS: tuple[str, ...] = ("state", "prior", "history")
6✔
117
_REQUIRE_BUFFERS: tuple[str, ...] = (
6✔
118
    "state",
119
    "prior",
120
    "history",
121
    "params",
122
    "scenario",
123
    "hyper",
124
)
125
_DICT_READ_METHODS: frozenset[str] = frozenset({"get", "items", "values", "keys"})
6✔
126

127

128
# ---------------------------------------------------------------------------
129
# AST primitives
130
# ---------------------------------------------------------------------------
131

132

133
def _parse_method(func: Callable) -> ast.Module | None:
6✔
134
    r"""Return the parsed AST of ``func`` or ``None`` on getsource failure.
135

136
    Pattern previously inlined at
137
    ``causality/docstring_causality_analyzer.py:109-129``; consumer becomes a
138
    one-line call once the analyzer is rewritten (dispatch #3).
139
    """
140
    try:
6✔
141
        src = inspect.getsource(func)
6✔
142
    except (OSError, TypeError):
6✔
143
        return None
6✔
144
    return ast.parse(textwrap.dedent(src))
6✔
145

146

147
def _method_aliases(cls: type) -> dict[str, list[str]]:
6✔
148
    r"""Resolve ``self.<name> = self.<target>`` and ``match``-block aliases
149
    written in ``cls.__init__``. Returns ``{alias: [target_variants]}``.
150

151
    Recognises:
152

153
    * Direct binding at top level of ``__init__``.
154
    * ``match``/``case`` branches binding the same alias to different targets.
155

156
    Out of scope (documented): ``IfExp`` chains, dict-dispatch, binding
157
    inside ``initialize``, lambda wrappers.
158
    """
159
    init = getattr(cls, "__init__", None)
6✔
160
    if init is None:
6✔
NEW
161
        return {}
×
162
    tree = _parse_method(init)
6✔
163
    if tree is None:
6✔
164
        return {}
6✔
165
    aliases: dict[str, list[str]] = {}
6✔
166
    for node in ast.walk(tree):
6✔
167
        if not isinstance(node, ast.Assign):
6✔
168
            continue
6✔
169
        if len(node.targets) != 1:
6✔
NEW
170
            continue
×
171
        tgt = node.targets[0]
6✔
172
        val = node.value
6✔
173
        if not (
6✔
174
            isinstance(tgt, ast.Attribute)
175
            and isinstance(tgt.value, ast.Name)
176
            and tgt.value.id == "self"
177
        ):
178
            continue
6✔
179
        if not (
6✔
180
            isinstance(val, ast.Attribute)
181
            and isinstance(val.value, ast.Name)
182
            and val.value.id == "self"
183
        ):
NEW
184
            continue
×
185
        aliases.setdefault(tgt.attr, []).append(val.attr)
6✔
186
    return aliases
6✔
187

188

189
def _self_calls(tree: ast.AST) -> list[str]:
6✔
190
    r"""Return the ordered list of method names called as ``self.<name>(...)``
191
    inside ``tree``. 3-line inline attribute-chain check; no helper extracted
192
    per the minimality audit (#4)."""
193
    calls: list[str] = []
6✔
194
    for node in ast.walk(tree):
6✔
195
        if not isinstance(node, ast.Call):
6✔
196
            continue
6✔
197
        f = node.func
6✔
198
        if (
6✔
199
            isinstance(f, ast.Attribute)
200
            and isinstance(f.value, ast.Name)
201
            and f.value.id == "self"
202
        ):
203
            calls.append(f.attr)
6✔
204
    return calls
6✔
205

206

207
def _reachable_methods(
6✔
208
    cls: type,
209
    root: str,
210
    exempt: set[str] | None = None,
211
) -> list[str]:
212
    r"""Return method names transitively reachable from ``cls.<root>`` via
213
    ``self.<name>(...)`` calls. Insertion-ordered (BFS); root included first.
214

215
    Resolves ``__init__``-bound aliases via :func:`_method_aliases`. MRO-walked
216
    via :func:`getattr` so child overrides resolve to the override, not the
217
    parent definition. ``exempt`` names are skipped during the walk (their
218
    bodies are not crawled for further callees).
219
    """
220
    if not hasattr(cls, root):
6✔
NEW
221
        return []
×
222
    exempt = exempt or set()
6✔
223
    aliases = _method_aliases(cls)
6✔
224
    seen: list[str] = []
6✔
225
    seen_set: set[str] = set()
6✔
226
    queue: list[str] = [root]
6✔
227
    while queue:
6✔
228
        name = queue.pop(0)
6✔
229
        if name in seen_set:
6✔
NEW
230
            continue
×
231
        method = getattr(cls, name, None)
6✔
232
        if method is None or not callable(method):
6✔
NEW
233
            continue
×
234
        seen.append(name)
6✔
235
        seen_set.add(name)
6✔
236
        if name in exempt:
6✔
NEW
237
            continue
×
238
        tree = _parse_method(method)
6✔
239
        if tree is None:
6✔
NEW
240
            continue
×
241
        for callee in _self_calls(tree):
6✔
242
            for resolved in aliases.get(callee, [callee]):
6✔
243
                if resolved not in seen_set and hasattr(cls, resolved):
6✔
244
                    queue.append(resolved)
6✔
245
    return seen
6✔
246

247

248
# ---------------------------------------------------------------------------
249
# Data shapes
250
# ---------------------------------------------------------------------------
251

252

253
@dataclass(frozen=True, slots=True)
6✔
254
class MethodSpec:
6✔
255
    r"""Declared and observed read/write footprint of a behavior method.
256

257
    Used both as author-declared spec (attached by decorators) and as
258
    walker-extracted observation (returned by :class:`_AccessVisitor`).
259
    :func:`lint_class` does set arithmetic between the two.
260

261
    The :data:`DYNAMIC` sentinel ``"*"`` is allowed as a member of any field;
262
    use the predicate ``DYNAMIC in spec.writes_state`` to detect unresolved
263
    accesses.
264
    """
265

266
    writes_state: frozenset[str] = frozenset()
6✔
267
    writes_prior: frozenset[str] = frozenset()
6✔
268
    writes_history: frozenset[str] = frozenset()
6✔
269
    requires_state: frozenset[str] = frozenset()
6✔
270
    requires_prior: frozenset[str] = frozenset()
6✔
271
    requires_history: frozenset[str] = frozenset()
6✔
272
    requires_params: frozenset[str] = frozenset()
6✔
273
    requires_scenario: frozenset[str] = frozenset()
6✔
274
    requires_hyper: frozenset[str] = frozenset()
6✔
275

276

277
class DriftStatus(Enum):
6✔
278
    r"""Per-method lint verdict, in priority order ``DRIFT`` > ``INDIRECT_WRITE``
279
    > ``DYNAMIC_UNRESOLVED`` > ``MISSING_DECORATOR`` > ``OK``."""
280

281
    OK = "ok"
6✔
282
    MISSING_DECORATOR = "missing_decorator"
6✔
283
    DRIFT = "drift"
6✔
284
    INDIRECT_WRITE = "indirect_write"
6✔
285
    DYNAMIC_UNRESOLVED = "dynamic_unresolved"
6✔
286

287

288
@dataclass(frozen=True, slots=True)
6✔
289
class DriftEntry:
6✔
290
    r"""One method's lint outcome."""
291

292
    method: str
6✔
293
    status: DriftStatus
6✔
294
    missing: frozenset[str] = frozenset()
6✔
295
    spurious: frozenset[str] = frozenset()
6✔
296
    dynamic_buffers: frozenset[str] = frozenset()
6✔
297
    indirect: bool = False
6✔
298

299

300
# ---------------------------------------------------------------------------
301
# Decorators
302
# ---------------------------------------------------------------------------
303

304

305
def _coerce(value, buffer: str) -> frozenset[str]:
6✔
306
    if not isinstance(value, (list, tuple, frozenset, set)):
6✔
307
        raise TypeError(
6✔
308
            f"method_spec decorator: kwarg {buffer!r} must be list/tuple/"
309
            f"frozenset of str (with optional DYNAMIC); got {type(value).__name__}"
310
        )
311
    for k in value:
6✔
312
        if not isinstance(k, str):
6✔
313
            raise TypeError(
6✔
314
                f"method_spec decorator: kwarg {buffer!r} entries must be str; "
315
                f"got {type(k).__name__}: {k!r}"
316
            )
317
    return frozenset(value)
6✔
318

319

320
def _merge(existing: frozenset[str], new: frozenset[str]) -> frozenset[str]:
6✔
321
    return existing | new
6✔
322

323

324
def writes(**kwargs):
6✔
325
    r"""Declare buffer writes.
326

327
    Accepts ``state``, ``prior``, ``history`` kwargs whose values are
328
    ``list``/``tuple``/``frozenset``/``set`` of strings (with optional
329
    :data:`DYNAMIC` sentinel). Sets ``func.__method_spec__`` only — no
330
    wrapping closure, zero per-call cost. Stacks via :func:`dataclasses.replace`
331
    when applied alongside :func:`requires` or another :func:`writes`.
332
    """
333
    unknown = set(kwargs) - set(_WRITE_BUFFERS)
6✔
334
    if unknown:
6✔
335
        raise TypeError(
6✔
336
            f"@writes only accepts buffer kwargs from {_WRITE_BUFFERS}; "
337
            f"got unknown: {sorted(unknown)}"
338
        )
339
    coerced = {buf: _coerce(kwargs[buf], buf) for buf in kwargs}
6✔
340

341
    def _attach(func):
6✔
342
        existing = getattr(func, "__method_spec__", None) or MethodSpec()
6✔
343
        replacement = {
6✔
344
            f"writes_{buf}": _merge(getattr(existing, f"writes_{buf}"), keys)
345
            for buf, keys in coerced.items()
346
        }
347
        func.__method_spec__ = dataclasses.replace(existing, **replacement)
6✔
348
        return func
6✔
349

350
    return _attach
6✔
351

352

353
def requires(**kwargs):
6✔
354
    r"""Declare buffer reads.
355

356
    Accepts ``state``, ``prior``, ``history``, ``params``, ``scenario``,
357
    ``hyper`` kwargs. Same value shape and zero-cost semantics as
358
    :func:`writes`.
359
    """
360
    unknown = set(kwargs) - set(_REQUIRE_BUFFERS)
6✔
361
    if unknown:
6✔
NEW
362
        raise TypeError(
×
363
            f"@requires only accepts buffer kwargs from {_REQUIRE_BUFFERS}; "
364
            f"got unknown: {sorted(unknown)}"
365
        )
366
    coerced = {buf: _coerce(kwargs[buf], buf) for buf in kwargs}
6✔
367

368
    def _attach(func):
6✔
369
        existing = getattr(func, "__method_spec__", None) or MethodSpec()
6✔
370
        replacement = {
6✔
371
            f"requires_{buf}": _merge(getattr(existing, f"requires_{buf}"), keys)
372
            for buf, keys in coerced.items()
373
        }
374
        func.__method_spec__ = dataclasses.replace(existing, **replacement)
6✔
375
        return func
6✔
376

377
    return _attach
6✔
378

379

380
# ---------------------------------------------------------------------------
381
# Mutator registry
382
# ---------------------------------------------------------------------------
383

384

385
_STATE_MUTATORS: dict[str, MethodSpec] = {}
6✔
386
r"""Registry of recognised state-mutating helper methods. Maps method name to
6✔
387
the :class:`MethodSpec` that name produces when called. The walker credits a
388
caller's observed accesses with the mutator's spec and sets the
389
:attr:`DriftEntry.indirect` flag for downstream classification.
390

391
Initially empty. Populated by :func:`register_mutator`; downstream dispatches
392
register specific helpers (e.g. ``Behavior.tanhmask`` is not a mutator, but
393
SFC-specific sector-update helpers are candidates)."""
394

395

396
def register_mutator(name: str, spec: MethodSpec) -> None:
6✔
397
    r"""Register ``name`` as a state-mutating helper with the given spec.
398

399
    Idempotent on identical re-register; raises :class:`ValueError` on
400
    conflict so silent test pollution is impossible. Import-time-only
401
    contract: do not call at request/step time.
402
    """
403
    existing = _STATE_MUTATORS.get(name)
6✔
404
    if existing is not None and existing != spec:
6✔
405
        raise ValueError(
6✔
406
            f"register_mutator({name!r}): conflicting spec already registered. "
407
            f"Existing: {existing}; new: {spec}."
408
        )
409
    _STATE_MUTATORS[name] = spec
6✔
410

411

412
# ---------------------------------------------------------------------------
413
# AST walker
414
# ---------------------------------------------------------------------------
415

416

417
def _literal_key(slice_node: ast.AST) -> str | None:
6✔
418
    if isinstance(slice_node, ast.Constant) and isinstance(slice_node.value, str):
6✔
419
        return slice_node.value
6✔
420
    return None
6✔
421

422

423
class _AccessVisitor(ast.NodeVisitor):
6✔
424
    r"""Walks a single method body and accumulates a :class:`MethodSpec`
425
    observation. The instance is single-use: construct, ``visit(tree)``, then
426
    read :attr:`observed` and :attr:`indirect`."""
427

428
    def __init__(self, tree: ast.Module):
6✔
429
        self._tree = tree
6✔
430
        self._writes: dict[str, set[str]] = {buf: set() for buf in _WRITE_BUFFERS}
6✔
431
        self._requires: dict[str, set[str]] = {buf: set() for buf in _REQUIRE_BUFFERS}
6✔
432
        self._aug_target_ids: set[int] = set()
6✔
433
        self._arg_names: set[str] = set()
6✔
434
        self.indirect: bool = False
6✔
435
        self._collect_aug_targets()
6✔
436
        self._collect_arg_names()
6✔
437

438
    def _collect_aug_targets(self) -> None:
6✔
439
        for node in ast.walk(self._tree):
6✔
440
            if isinstance(node, ast.AugAssign) and isinstance(
6✔
441
                node.target, ast.Subscript
442
            ):
443
                self._aug_target_ids.add(id(node.target))
6✔
444

445
    def _collect_arg_names(self) -> None:
6✔
446
        if not self._tree.body:
6✔
NEW
447
            return
×
448
        fd = self._tree.body[0]
6✔
449
        if not isinstance(fd, ast.FunctionDef):
6✔
NEW
450
            return
×
451
        self._arg_names |= {a.arg for a in fd.args.posonlyargs}
6✔
452
        self._arg_names |= {a.arg for a in fd.args.args}
6✔
453
        self._arg_names |= {a.arg for a in fd.args.kwonlyargs}
6✔
454
        if fd.args.vararg is not None:
6✔
NEW
455
            self._arg_names.add(fd.args.vararg.arg)
×
456
        if fd.args.kwarg is not None:
6✔
457
            self._arg_names.add(fd.args.kwarg.arg)
6✔
458

459
    @property
6✔
460
    def observed(self) -> MethodSpec:
6✔
461
        return MethodSpec(
6✔
462
            writes_state=frozenset(self._writes["state"]),
463
            writes_prior=frozenset(self._writes["prior"]),
464
            writes_history=frozenset(self._writes["history"]),
465
            requires_state=frozenset(self._requires["state"])
466
            - frozenset(self._writes["state"]),
467
            requires_prior=frozenset(self._requires["prior"])
468
            - frozenset(self._writes["prior"]),
469
            requires_history=frozenset(self._requires["history"])
470
            - frozenset(self._writes["history"]),
471
            requires_params=frozenset(self._requires["params"]),
472
            requires_scenario=frozenset(self._requires["scenario"]),
473
            requires_hyper=frozenset(self._requires["hyper"]),
474
        )
475

476
    @staticmethod
6✔
477
    def _self_attr_name(node: ast.AST) -> str | None:
6✔
478
        if (
6✔
479
            isinstance(node, ast.Attribute)
480
            and isinstance(node.value, ast.Name)
481
            and node.value.id == "self"
482
        ):
483
            return node.attr
6✔
484
        return None
6✔
485

486
    def _record_write(self, buffer: str, key: str | None) -> None:
6✔
487
        if buffer not in self._writes:
6✔
NEW
488
            return
×
489
        self._writes[buffer].add(DYNAMIC if key is None else key)
6✔
490

491
    def _record_require(self, buffer: str, key: str | None) -> None:
6✔
492
        if buffer not in self._requires:
6✔
NEW
493
            return
×
494
        self._requires[buffer].add(DYNAMIC if key is None else key)
6✔
495

496
    def visit_AugAssign(self, node: ast.AugAssign) -> None:
6✔
497
        tgt = node.target
6✔
498
        if isinstance(tgt, ast.Subscript):
6✔
499
            buffer = self._resolve_subscript_buffer(tgt.value)
6✔
500
            if buffer is not None:
6✔
501
                key = _literal_key(tgt.slice)
6✔
502
                self._record_write(buffer, key)
6✔
503
                self._record_require(buffer, key)
6✔
504
        self.generic_visit(node)
6✔
505

506
    def visit_Subscript(self, node: ast.Subscript) -> None:
6✔
507
        if id(node) in self._aug_target_ids:
6✔
508
            self.generic_visit(node)
6✔
509
            return
6✔
510
        buffer = self._resolve_subscript_buffer(node.value)
6✔
511
        if buffer is None:
6✔
512
            self.generic_visit(node)
6✔
513
            return
6✔
514
        key = _literal_key(node.slice)
6✔
515
        if isinstance(node.ctx, ast.Store):
6✔
516
            self._record_write(buffer, key)
6✔
517
        else:
518
            self._record_require(buffer, key)
6✔
519
        self.generic_visit(node)
6✔
520

521
    def _resolve_subscript_buffer(self, value_node: ast.AST) -> str | None:
6✔
522
        attr = self._self_attr_name(value_node)
6✔
523
        if attr == "state":
6✔
524
            return "state"
6✔
525
        if attr == "prior":
6✔
526
            return "prior"
6✔
527
        if attr == "history":
6✔
NEW
528
            return "history"
×
529
        if attr == "params":
6✔
530
            return "params"
6✔
531
        if attr == "scenarios":
6✔
532
            return "scenario"
6✔
533
        if attr == "hyper":
6✔
534
            return "hyper"
6✔
535
        if isinstance(value_node, ast.Name):
6✔
536
            if value_node.id == "scenario" and "scenario" in self._arg_names:
6✔
537
                return "scenario"
6✔
538
            if value_node.id == "params" and "params" in self._arg_names:
6✔
539
                return "params"
6✔
540
        return None
6✔
541

542
    def visit_Call(self, node: ast.Call) -> None:
6✔
543
        f = node.func
6✔
544
        if (
6✔
545
            isinstance(f, ast.Attribute)
546
            and isinstance(f.value, ast.Name)
547
            and f.value.id == "self"
548
            and f.attr in _STATE_MUTATORS
549
        ):
550
            mspec = _STATE_MUTATORS[f.attr]
6✔
551
            for buf in _WRITE_BUFFERS:
6✔
552
                self._writes[buf] |= getattr(mspec, f"writes_{buf}")
6✔
553
            for buf in _REQUIRE_BUFFERS:
6✔
554
                self._requires[buf] |= getattr(mspec, f"requires_{buf}")
6✔
555
            self.indirect = True
6✔
556
        elif isinstance(f, ast.Attribute) and f.attr in _DICT_READ_METHODS:
6✔
557
            buffer = self._resolve_subscript_buffer(f.value)
6✔
558
            if buffer is not None:
6✔
559
                if f.attr == "get":
6✔
560
                    key = _literal_key(node.args[0]) if node.args else None
6✔
561
                    self._record_require(buffer, key)
6✔
562
                else:
563
                    self._record_require(buffer, None)
6✔
564
        self.generic_visit(node)
6✔
565

566

567
def _observe(func: Callable) -> tuple[MethodSpec | None, bool]:
6✔
568
    r"""Return ``(observed_spec, indirect_flag)`` for ``func`` or ``(None, False)``
569
    when getsource fails (lambdas, runtime-bound staticmethods)."""
570
    tree = _parse_method(func)
6✔
571
    if tree is None:
6✔
NEW
572
        return None, False
×
573
    visitor = _AccessVisitor(tree)
6✔
574
    visitor.visit(tree)
6✔
575
    return visitor.observed, visitor.indirect
6✔
576

577

578
# ---------------------------------------------------------------------------
579
# Lint entry point
580
# ---------------------------------------------------------------------------
581

582

583
_ALL_FIELDS: tuple[str, ...] = (
6✔
584
    "writes_state",
585
    "writes_prior",
586
    "writes_history",
587
    "requires_state",
588
    "requires_prior",
589
    "requires_history",
590
    "requires_params",
591
    "requires_scenario",
592
    "requires_hyper",
593
)
594

595

596
def _touches_buffers(spec: MethodSpec) -> bool:
6✔
597
    return any(getattr(spec, f) for f in _ALL_FIELDS)
6✔
598

599

600
def _strip_dynamic(s: frozenset[str]) -> frozenset[str]:
6✔
601
    return s - {DYNAMIC}
6✔
602

603

604
def _classify(
6✔
605
    declared: MethodSpec,
606
    observed: MethodSpec,
607
    indirect: bool,
608
) -> tuple[DriftStatus, frozenset[str], frozenset[str], frozenset[str]]:
609
    r"""Return ``(status, missing, spurious, dynamic_buffers)``.
610

611
    Missing is the union of literal keys observed but not declared, across
612
    all buffer fields. Spurious is the reverse. ``dynamic_buffers`` is the
613
    set of field names where observed has the :data:`DYNAMIC` sentinel but
614
    declared does not.
615
    """
616
    missing: set[str] = set()
6✔
617
    spurious: set[str] = set()
6✔
618
    dyn_fields: set[str] = set()
6✔
619
    for f in _ALL_FIELDS:
6✔
620
        obs = getattr(observed, f)
6✔
621
        dec = getattr(declared, f)
6✔
622
        obs_lit = _strip_dynamic(obs)
6✔
623
        dec_lit = _strip_dynamic(dec)
6✔
624
        missing |= obs_lit - dec_lit
6✔
625
        spurious |= dec_lit - obs_lit
6✔
626
        if DYNAMIC in obs and DYNAMIC not in dec:
6✔
627
            dyn_fields.add(f)
6✔
628
    has_drift = bool(missing or spurious)
6✔
629
    dynamic_buffers = frozenset(dyn_fields)
6✔
630
    if has_drift:
6✔
631
        if indirect and not spurious and missing:
6✔
NEW
632
            return (
×
633
                DriftStatus.INDIRECT_WRITE,
634
                frozenset(missing),
635
                frozenset(spurious),
636
                dynamic_buffers,
637
            )
638
        return (
6✔
639
            DriftStatus.DRIFT,
640
            frozenset(missing),
641
            frozenset(spurious),
642
            dynamic_buffers,
643
        )
644
    if indirect:
6✔
645
        return DriftStatus.INDIRECT_WRITE, frozenset(), frozenset(), dynamic_buffers
6✔
646
    if dynamic_buffers:
6✔
647
        return DriftStatus.DYNAMIC_UNRESOLVED, frozenset(), frozenset(), dynamic_buffers
6✔
648
    return DriftStatus.OK, frozenset(), frozenset(), dynamic_buffers
6✔
649

650

651
def lint_class(
6✔
652
    cls: type,
653
    root: str = "step",
654
    exempt: set[str] | None = None,
655
) -> list[DriftEntry]:
656
    r"""Walk every method reachable from ``cls.<root>`` and return one
657
    :class:`DriftEntry` per method that touches any tracked buffer.
658

659
    Methods that do not touch ``state``/``prior``/``history``/``params``/
660
    ``scenarios``/``hyper`` (or their kwarg forms) are skipped — no
661
    decorator expected.
662

663
    Truthy check: ``bad = [e for e in result if e.status is not DriftStatus.OK]``.
664
    """
665
    exempt = exempt or set()
6✔
666
    entries: list[DriftEntry] = []
6✔
667
    for name in _reachable_methods(cls, root, exempt=exempt):
6✔
668
        if name in exempt:
6✔
NEW
669
            continue
×
670
        func = getattr(cls, name, None)
6✔
671
        if func is None or not callable(func):
6✔
NEW
672
            continue
×
673
        observed, indirect = _observe(func)
6✔
674
        if observed is None:
6✔
NEW
675
            continue
×
676
        declared = getattr(func, "__method_spec__", None)
6✔
677
        if (
6✔
678
            not _touches_buffers(observed)
679
            and not indirect
680
            and (declared is None or not _touches_buffers(declared))
681
        ):
682
            continue
6✔
683
        if declared is None:
6✔
684
            entries.append(
6✔
685
                DriftEntry(
686
                    method=name,
687
                    status=DriftStatus.MISSING_DECORATOR,
688
                    missing=frozenset(
689
                        k
690
                        for f in _ALL_FIELDS
691
                        for k in _strip_dynamic(getattr(observed, f))
692
                    ),
693
                    dynamic_buffers=frozenset(
694
                        f for f in _ALL_FIELDS if DYNAMIC in getattr(observed, f)
695
                    ),
696
                    indirect=indirect,
697
                )
698
            )
699
            continue
6✔
700
        status, missing, spurious, dyn_fields = _classify(declared, observed, indirect)
6✔
701
        entries.append(
6✔
702
            DriftEntry(
703
                method=name,
704
                status=status,
705
                missing=missing,
706
                spurious=spurious,
707
                dynamic_buffers=dyn_fields,
708
                indirect=indirect,
709
            )
710
        )
711
    return entries
6✔
712

713

714
def counts(entries: list[DriftEntry]) -> dict[DriftStatus, int]:
6✔
715
    r"""Return ``{DriftStatus: count}`` over ``entries``, with every status
716
    keyed (zero for absent statuses) so downstream dashboards can rely on
717
    stable shape."""
718
    out = {s: 0 for s in DriftStatus}
6✔
719
    for e in entries:
6✔
720
        out[e.status] = out.get(e.status, 0) + 1
6✔
721
    return out
6✔
722

723

724
# ---------------------------------------------------------------------------
725
# R6d: non-buffer instance attribute rule
726
# ---------------------------------------------------------------------------
727

728

729
_TRACKED_BUFFERS: frozenset[str] = frozenset(
6✔
730
    {"state", "prior", "history", "params", "scenarios", "hyper"}
731
)
732

733
_INIT_EXEMPT_METHODS: frozenset[str] = frozenset({"__init__", "initialize"})
6✔
734

735
_INIT_NAMES_CACHE: dict[type, frozenset[str]] = {}
6✔
736

737

738
def _init_attr_names(cls: type) -> frozenset[str]:
6✔
739
    r"""Return names assigned via ``self.<name> = ...`` in ``cls.__init__``.
740

741
    Walks only ``cls.__init__`` (not parent or ``initialize``). Used by R6d
742
    to auto-allowlist reads of init-bound names in step methods. Per-class
743
    cache keyed on the class object.
744
    """
745
    cached = _INIT_NAMES_CACHE.get(cls)
6✔
746
    if cached is not None:
6✔
747
        return cached
6✔
748
    init = cls.__dict__.get("__init__")
6✔
749
    if init is None:
6✔
NEW
750
        result: frozenset[str] = frozenset()
×
NEW
751
        _INIT_NAMES_CACHE[cls] = result
×
NEW
752
        return result
×
753
    tree = _parse_method(init)
6✔
754
    if tree is None:
6✔
NEW
755
        result = frozenset()
×
NEW
756
        _INIT_NAMES_CACHE[cls] = result
×
NEW
757
        return result
×
758
    names: set[str] = set()
6✔
759
    for node in ast.walk(tree):
6✔
760
        if isinstance(node, (ast.Assign, ast.AugAssign, ast.AnnAssign)):
6✔
761
            targets = node.targets if isinstance(node, ast.Assign) else [node.target]
6✔
762
            for tgt in targets:
6✔
763
                if (
6✔
764
                    isinstance(tgt, ast.Attribute)
765
                    and isinstance(tgt.value, ast.Name)
766
                    and tgt.value.id == "self"
767
                ):
768
                    names.add(tgt.attr)
6✔
769
    result = frozenset(names)
6✔
770
    _INIT_NAMES_CACHE[cls] = result
6✔
771
    return result
6✔
772

773

774
def check_non_buffer_attrs(cls: type) -> list[tuple[str, str, int, str]]:
6✔
775
    r"""Enforce the R6d rule on ``cls``.
776

777
    For each method other than ``__init__`` and ``initialize``:
778

779
    * **Writes** ``self.<name> = ...`` outside the init pair are violations
780
      unless ``<name>`` is a tracked buffer or starts with ``_``. Writes are
781
      NOT exempt for ``__init__``-bound names — variant (b) closes the
782
      canary-binding evasion where a class declares a name in ``__init__``
783
      purely so step methods can scribble on it.
784
    * **Reads** ``self.<name>`` (Attribute Load context) are allowed if
785
      ``<name>`` is a tracked buffer, starts with ``_``, or is in
786
      :func:`_init_attr_names`. Method-name reads (``self.foo()``) are not
787
      checked — those are call-graph edges handled by the lint.
788

789
    Returns ``[(method, attr, line, reason), ...]``. The walker skips
790
    ``Behavior`` itself; only strict subclasses are checked.
791

792
    Limitations:
793

794
    * ``setattr(self, name, value)`` is invisible to the AST walker.
795
    * Reads of parent ``__init__``-bound names are allowed because Python
796
      class semantics dispatch ``cls.__init__`` to the inherited definition
797
      when not overridden, so :func:`_init_attr_names` walks the inherited
798
      ``__init__`` AST.
799
    """
800
    from macrostat.core.behavior import Behavior  # local: avoid import cycle
6✔
801

802
    if cls is Behavior:
6✔
803
        return []
6✔
804
    init_names = _init_attr_names(cls)
6✔
805
    violations: list[tuple[str, str, int, str]] = []
6✔
806
    for name, member in inspect.getmembers(cls, predicate=callable):
6✔
807
        if name in _INIT_EXEMPT_METHODS:
6✔
808
            continue
6✔
809
        if name not in cls.__dict__:
6✔
810
            continue
6✔
811
        tree = _parse_method(member)
6✔
812
        if tree is None:
6✔
NEW
813
            continue
×
814
        for node in ast.walk(tree):
6✔
815
            if isinstance(node, ast.Assign):
6✔
816
                for tgt in node.targets:
6✔
817
                    if (
6✔
818
                        isinstance(tgt, ast.Attribute)
819
                        and isinstance(tgt.value, ast.Name)
820
                        and tgt.value.id == "self"
821
                    ):
822
                        attr = tgt.attr
6✔
823
                        if attr in _TRACKED_BUFFERS or attr.startswith("_"):
6✔
824
                            continue
6✔
825
                        violations.append(
6✔
826
                            (
827
                                name,
828
                                attr,
829
                                node.lineno,
830
                                "R6d: write to non-buffer non-private attr outside __init__/initialize",
831
                            )
832
                        )
833
            elif isinstance(node, ast.Attribute) and isinstance(node.ctx, ast.Load):
6✔
834
                if isinstance(node.value, ast.Name) and node.value.id == "self":
6✔
835
                    attr = node.attr
6✔
836
                    if (
6✔
837
                        attr in _TRACKED_BUFFERS
838
                        or attr.startswith("_")
839
                        or attr in init_names
840
                    ):
841
                        continue
6✔
842
                    if callable(getattr(cls, attr, None)):
6✔
843
                        continue
6✔
844
                    violations.append(
6✔
845
                        (
846
                            name,
847
                            attr,
848
                            node.lineno,
849
                            "R6d: read of non-buffer attr not bound in __init__",
850
                        )
851
                    )
852
    return violations
6✔
STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc