scripts.scan_imperative_accumulators

Reference › scripts › scan_imperative_accumulators

scripts/scan_imperative_accumulators.py inventories every IMPERATIVE_ACCUMULATOR site in the tree: loops that only accumulate into a variable. A human runs it to measure how much code a style rule affects; tests/scripts/test_scan_imperative_accumulators.py covers it. It does not duplicate the rule: _style_checker loads the live StyleChecker class from its hyphenated module path, and sites returns the line, function and variable of each accumulate-only loop in a parsed tree.

Arguments: one or more files or directories (roots) and --json. Directories are walked for *.py, and files that do not parse are skipped. The default output is a per-file count, largest first, followed by a total of sites across files. With --json it prints the findings as a list of objects with file, line, function and variable. It reads only and always exits 0.

At a glance

Source scripts/scan_imperative_accumulators.py
Lines of code 107
Classes 0
Functions 3
Imports (internal) none
Imported by none
Tests that import it tests/scripts/test_scan_imperative_accumulators.py

Class diagram

No classes; see Functions.

Dependency diagram

No internal imports and no importers.

API

Functions

Function Signature Summary
_style_checker _style_checker() -> Any The live StyleChecker class, loaded from the hyphenated module path.
sites sites(checker: Any, tree: ast.Module) -> list[tuple[int, str, str]] (line, function, variable) for every accumulate-only loop in tree.
main main(argv: list[str] | None = None) -> int

Source

Permalink: https://gitlab.com/MSamy/orchestrata-ultimate-architecture/-/blob/a4cbc2bdf/scripts/scan_imperative_accumulators.py

#!/usr/bin/env python3
"""Inventory every IMPERATIVE_ACCUMULATOR site in the tree.

The rule itself lives in ``scripts/code-review.py`` (StyleChecker); running the
full reviewer over the whole tree also runs ruff and mypy per file, which is
minutes of work when all that is wanted is the accumulator census. This reuses
the SAME atoms and facts so the two can never disagree -- it imports the rule's
own helpers rather than restating the pattern.

Usage:
    python scripts/scan_imperative_accumulators.py src scripts
    python scripts/scan_imperative_accumulators.py --json src
"""

from __future__ import annotations

import argparse
import ast
import importlib.util
import json
import sys
from collections import Counter
from pathlib import Path
from typing import Any

_REVIEWER = Path(__file__).with_name("code-review.py")


def _style_checker() -> Any:
    """The live StyleChecker class, loaded from the hyphenated module path."""
    spec = importlib.util.spec_from_file_location("code_review_tool", _REVIEWER)
    if spec is None or spec.loader is None:
        raise SystemExit(f"cannot load {_REVIEWER}")
    module = importlib.util.module_from_spec(spec)
    sys.modules["code_review_tool"] = module
    spec.loader.exec_module(module)
    return module.StyleChecker


def sites(checker: Any, tree: ast.Module) -> list[tuple[int, str, str]]:
    """(line, function, variable) for every accumulate-only loop in ``tree``."""
    scopes = [
        n
        for n in ast.walk(tree)
        if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
    ]
    found: list[tuple[int, str, str]] = []
    for scope in scopes:
        inits = checker._accumulator_inits(scope)
        if not inits:
            continue
        loops = [n for n in ast.walk(scope) if isinstance(n, (ast.For, ast.AsyncFor))]
        for loop in loops:
            if not loop.body or not all(
                isinstance(stmt, ast.Expr)
                and isinstance(stmt.value, ast.Call)
                and isinstance(stmt.value.func, ast.Attribute)
                and stmt.value.func.attr in checker._ACCUMULATOR_METHODS
                and isinstance(stmt.value.func.value, ast.Name)
                and stmt.value.func.value.id in inits
                for stmt in loop.body
            ):
                continue
            found.extend(
                (loop.lineno, scope.name, var)
                for var in sorted(checker._loop_mutations(loop) & inits.keys())
            )
    return found


def main(argv: list[str] | None = None) -> int:
    parser = argparse.ArgumentParser(description=__doc__)
    parser.add_argument("roots", nargs="+", help="directories or files to scan")
    parser.add_argument("--json", action="store_true", help="machine-readable output")
    args = parser.parse_args(argv)

    checker = _style_checker()
    files = [
        f
        for root in map(Path, args.roots)
        for f in ([root] if root.is_file() else sorted(root.rglob("*.py")))
    ]

    findings: list[dict[str, Any]] = []
    for path in files:
        try:
            tree = ast.parse(path.read_text(encoding="utf-8"))
        except SyntaxError, UnicodeDecodeError:
            continue
        findings.extend(
            {"file": str(path), "line": line, "function": func, "var": var}
            for line, func, var in sites(checker, tree)
        )

    if args.json:
        print(json.dumps(findings, indent=2))
        return 0

    by_file = Counter(f["file"] for f in findings)
    for path, count in by_file.most_common():
        print(f"{count:4d}  {path}")
    print(f"\n{len(findings)} sites across {len(by_file)} files ({len(files)} scanned)")
    return 0


if __name__ == "__main__":
    sys.exit(main())