scripts.rts

Reference › scripts › rts

scripts/rts.py does AST-based safe regression test selection over a Prolog knowledge base; it is a handover artifact, covered by tests/scripts/test_rts.py. A human or a CI step runs it. It has two positional-argument commands rather than options.

rts.py extract <source-dir> <out.pl> calls extract, which writes Prolog facts for the source tree into the output file, including per-symbol hashes (sym_hash) and per-file hashes (file_hash). rts.py select <old.pl> <new.pl> reads two such fact files with parse_pred, and works out the changed symbols: edited, added or deleted. Any changed, added or removed non-Python file adds a safety_escape fact, which forces a full run. It then writes a temporary Prolog program made of the selection rules, the new fact base, the escapes and the changed/1 facts, prints the changed symbols, and runs it with swipl, which prints the selection. Wrong usage prints the module summary and exits 2.

At a glance

Source scripts/rts.py
Lines of code 308
Classes 0
Functions 4
Imports (internal) none
Imported by none
Tests that import it tests/scripts/test_rts.py

Class diagram

No classes; see Functions.

Dependency diagram

No internal imports and no importers.

API

Functions

Function Signature Summary
q q(s: object) -> str
extract extract(root: pathlib.Path) -> str
parse_pred parse_pred(text: str, pred: str) -> dict[str, str] Read binary facts pred('Key', 'Val'). back into a dict.
main main() -> int

Source

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

#!/usr/bin/env python3
"""AST-based safe regression test selection over a Prolog KB (handover artifact).

    python scripts/rts.py extract <root> <snapshot.pl>   symbol/call/hash facts from AST
    python scripts/rts.py select  <old.pl> <new.pl>      diff hashes -> changed symbols
                                                         -> tabled reverse reachability
                                                         -> minimal safe test set (stdout)

Selection criterion (Rothermel-Harrold safe over-approximation): a test is
selected iff it is changed itself, or a changed symbol is in its transitive
call/import closure. Safety escapes (conftest, unparsed files, unresolved
dynamic calls) force full-run and are printed as such.

Provenance: delivered by the 2026-07-31 orchestrata handover
(~/workspace/orchestrata-handover/rts.py), verified there on 4 behavioral
cases + ground truth (selected set == actually-failing set, zero misses).
Re-verified on this host 2026-08-02. Installed 2026-08-02 per the tooling
wiring plan (plans/tooling-wiring.md).

Relationship to scripts/affected_tests.py (the WIRED selector behind
``run_tests.py --affected``): affected_tests works at MODULE granularity via
the serialized import closure + manifest + full-suite escalation; this tool
works at SYMBOL granularity via per-function AST source hashes (formatting
blind). The handover's Phase 7 merges this precision INTO affected_tests —
until that lands (gated on user authorization), this stays a standalone
second opinion: run it when a module-level selection looks too broad.

Defects fixed on install (2026-08-02), diverging from the handover artifact:
1. Modules were keyed by file STEM, so package-qualified call edges
   ("src.calc.add") never matched defined symbols ("calc.add") — silent
   UNSAFE under-selection on any package layout. Modules are now keyed by
   dotted path from root; relative imports resolve against the importing
   package (unresolvable ones become safety escapes).
2. Only top-level functions were hashed — methods, classes, nested defs,
   decorators, imports, constants, and module-level statements were captured
   by NOTHING (edits there selected zero tests). Capture is now
   BYTE-COMPLETE (user mandate): every def/class at any depth is hashed on
   its exact source segment, and a synthetic ``<mod>.<module>`` symbol
   hashes the residue (everything outside top-level def/class spans), with
   dependency edges symbol→its module and module→imported modules. Any
   single-byte change in any .py file changes at least one hash.
Tests are detected under a ``tests/`` directory (functions/methods named
``test_*``).
"""

import ast
import hashlib
import pathlib
import subprocess
import sys
import tempfile


def q(s: object) -> str:  # quote as Prolog atom
    return "'" + str(s).replace("\\", "\\\\").replace("'", "\\'") + "'"


def extract(root: pathlib.Path) -> str:
    facts: list[str] = []
    escapes: list[str] = []
    for path in sorted(root.rglob("*.py")):
        rel = path.relative_to(root)
        # Key modules by their dotted import path from root (src.calc, not
        # the bare stem "calc"). The original handover artifact used
        # path.stem, so defined symbols ("calc.add") could NEVER match
        # package-qualified call edges ("src.calc.add") — silent UNSAFE
        # under-selection. Found by smoke test on install (2026-08-02).
        mod = ".".join(rel.with_suffix("").parts)
        pkg_parts = rel.parts[:-1]
        try:
            src = path.read_text()
            tree = ast.parse(src)
        except SyntaxError:
            escapes.append(f"unparsed:{rel}")
            continue
        if path.name == "conftest.py":
            escapes.append(f"conftest:{rel}")

        imports: dict[str, str] = {}  # local name -> qualified target
        for node in ast.walk(tree):
            if isinstance(node, ast.Import):
                for a in node.names:
                    imports[a.asname or a.name] = a.name
            elif isinstance(node, ast.ImportFrom):
                if node.level == 0:
                    base = node.module or ""
                else:  # relative import: resolve against this module's package
                    drop = node.level - 1
                    if drop > len(pkg_parts):
                        escapes.append(f"unresolved-relative-import:{rel}")
                        continue
                    kept = pkg_parts[: len(pkg_parts) - drop]
                    base = ".".join((*kept, node.module) if node.module else kept)
                for a in node.names:
                    imports[a.asname or a.name] = f"{base}.{a.name}" if base else a.name

        local_fns = {
            n.name
            for n in tree.body
            if isinstance(n, (ast.FunctionDef, ast.AsyncFunctionDef))
        }

        # BYTE-COMPLETE CAPTURE (user mandate 2026-08-02: "AST must capture
        # byte-identical code, all of the code"). Every byte of the file lands
        # in exactly one hash bucket:
        #   1. every def/async def/class at ANY depth -> sym_hash of its exact
        #      source segment (qualified name; methods/nested defs included);
        #   2. a synthetic "<mod>.<module>" symbol hashes the RESIDUE — the
        #      file with top-level def/class line-spans blanked (imports,
        #      constants, decorators, module-level statements);
        #   3. a segment the AST cannot source byte-exactly is a safety
        #      escape (FULL-RUN), never a silent empty hash.
        # Propagation: every symbol depends on its module's <module> symbol;
        # a module depends on every imported module's <module> symbol (all
        # dotted prefixes — over-approximation is SAFE, nonexistent prefixes
        # never match a changed fact).
        mod_sym = f"{mod}.<module>"
        facts.append(f"defines({q(mod)}, {q(mod_sym)}).")

        def record_call_edges(qname: str, node: ast.AST) -> None:
            for c in ast.walk(node):
                if not isinstance(c, ast.Call):
                    continue
                f, callee = c.func, None
                if isinstance(f, ast.Name):
                    if f.id in imports:
                        callee = imports[f.id]
                    elif f.id in local_fns:
                        callee = f"{mod}.{f.id}"
                elif (
                    isinstance(f, ast.Attribute)
                    and isinstance(f.value, ast.Name)
                    and f.value.id in imports
                ):
                    callee = f"{imports[f.value.id]}.{f.attr}"
                elif isinstance(f, ast.Attribute):
                    pass  # method call on object: covered by module-level edges
                else:
                    escapes.append(f"dynamic-call:{qname}")  # getattr etc.
                if callee:
                    facts.append(f"calls({q(qname)}, {q(callee)}).")

        def walk_defs(body: list[ast.stmt], prefix: str) -> None:
            for node in body:
                if not isinstance(
                    node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
                ):
                    continue
                qname = f"{prefix}.{node.name}"
                segment = ast.get_source_segment(src, node)
                if segment is None:
                    escapes.append(f"unhashable:{qname}")
                else:
                    h = hashlib.sha256(segment.encode()).hexdigest()[:16]
                    facts.append(f"defines({q(mod)}, {q(qname)}).")
                    facts.append(f"sym_hash({q(qname)}, {q(h)}).")
                facts.append(f"calls({q(qname)}, {q(mod_sym)}).")
                if "tests" in rel.parts and node.name.startswith("test_"):
                    facts.append(f"is_test({q(qname)}).")
                if isinstance(node, ast.ClassDef):
                    walk_defs(node.body, qname)
                else:
                    record_call_edges(qname, node)
                    walk_defs(node.body, qname)  # nested defs get own hashes

        walk_defs(tree.body, mod)

        # Residue: the file with TOP-LEVEL def/class line-spans blanked.
        # Decorator lines precede node.lineno, so they stay in the residue.
        lines = src.splitlines(keepends=True)
        blanked: set[int] = set()
        for node in tree.body:
            if isinstance(node, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)):
                end = node.end_lineno or node.lineno
                blanked.update(range(node.lineno - 1, end))
        residue = "".join(ln for i, ln in enumerate(lines) if i not in blanked)
        rh = hashlib.sha256(residue.encode()).hexdigest()[:16]
        facts.append(f"sym_hash({q(mod_sym)}, {q(rh)}).")
        for stmt in tree.body:  # module-level calls only — defs have their own
            if not isinstance(
                stmt, (ast.FunctionDef, ast.AsyncFunctionDef, ast.ClassDef)
            ):
                record_call_edges(mod_sym, stmt)

        # Module import edges: <module> -> every dotted prefix's <module>.
        for target in set(imports.values()):
            parts = target.split(".")
            for i in range(1, len(parts) + 1):
                pref = ".".join(parts[:i])
                if pref != mod:
                    facts.append(f"calls({q(mod_sym)}, {q(pref + '.<module>')}).")
    # NON-PYTHON capture ("all of the code, leave nothing behind"): every
    # non-.py file gets a byte-exact file_hash fact. select treats any
    # changed/added/removed non-Python file as a safety escape (FULL-RUN) —
    # .pl rule files, YAML, hooks, templates are code this tool cannot
    # attribute to symbols, so it must never silently ignore them.
    skip_dirs = {
        ".git",
        "__pycache__",
        ".venv",
        "node_modules",
        ".pytest_cache",
        ".ruff_cache",
        ".mypy_cache",
    }
    for path in sorted(root.rglob("*")):
        if not path.is_file() or path.suffix == ".py":
            continue
        rel = path.relative_to(root)
        if skip_dirs.intersection(rel.parts) or rel.parts[:2] == (".claude", "run"):
            continue  # VCS/caches/venvs + volatile runtime dir only
        h = hashlib.sha256(path.read_bytes()).hexdigest()[:16]
        facts.append(f"file_hash({q(rel)}, {q(h)}).")

    facts.extend(f"safety_escape({q(e)})." for e in sorted(set(escapes)))
    return (
        "\n".join(
            f":- discontiguous({p}).\n"
            for p in [
                "defines/2",
                "sym_hash/2",
                "is_test/1",
                "calls/2",
                "safety_escape/1",
                "file_hash/2",
            ]
        )
        + "\n".join(sorted(set(facts)))
        + "\n"
    )


SELECT_PL = r"""
:- dynamic calls/2 as incremental.
:- dynamic changed/1 as incremental.
:- table reaches/2 as incremental.
:- discontiguous(defines/2). :- discontiguous(sym_hash/2).
:- discontiguous(is_test/1). :- discontiguous(safety_escape/1).
:- discontiguous(file_hash/2).
:- dynamic defines/2, sym_hash/2, is_test/1, safety_escape/1, file_hash/2.

reaches(A, B) :- calls(A, B).
reaches(A, B) :- calls(A, M), reaches(M, B).

select_test(T) :- is_test(T), changed(T).
select_test(T) :- is_test(T), changed(S), reaches(T, S).

main :-
    ( safety_escape(E) -> format("FULL-RUN ~w~n", [E])
    ; forall(( is_test(T), ( select_test(T) -> V = 'SELECT' ; V = 'skip' )),
             format("~w ~w~n", [V, T]))
    ).
"""


def parse_pred(text: str, pred: str) -> dict[str, str]:
    """Read binary facts ``pred('Key', 'Val').`` back into a dict."""
    out: dict[str, str] = {}
    prefix = pred + "("
    for line in text.splitlines():
        if line.startswith(prefix):
            key, val = line[len(prefix) : -2].split(", ")
            out[key] = val
    return out


def main() -> int:
    if len(sys.argv) < 4 or sys.argv[1] not in ("extract", "select"):
        print((__doc__ or "").split("\n\n")[0].strip(), file=sys.stderr)
        return 2
    cmd = sys.argv[1]
    if cmd == "extract":
        pathlib.Path(sys.argv[3]).write_text(extract(pathlib.Path(sys.argv[2])))
    else:  # select
        old_text = pathlib.Path(sys.argv[2]).read_text()
        new_text = pathlib.Path(sys.argv[3]).read_text()
        old = parse_pred(old_text, "sym_hash")
        new = parse_pred(new_text, "sym_hash")
        # any changed/added/removed NON-PYTHON file forces FULL-RUN
        fo = parse_pred(old_text, "file_hash")
        fn = parse_pred(new_text, "file_hash")
        file_escapes = "".join(
            f"safety_escape(changed_file({p})).\n"
            for p in sorted(
                {p for p, h in fn.items() if fo.get(p) != h} | (set(fo) - set(fn))
            )
        )
        changed = {s for s, h in new.items() if old.get(s) != h} | (
            set(old) - set(new)
        )  # edited/added | deleted
        script = (
            SELECT_PL
            + new_text
            + file_escapes
            + "".join(f"changed({s}).\n" for s in sorted(changed))
        )
        with tempfile.NamedTemporaryFile(
            "w", suffix=".pl", prefix="rts_select_", delete=False
        ) as fh:
            fh.write(script)
            tmp = fh.name
        print("changed:", ", ".join(sorted(changed)) or "(none)")
        subprocess.run(["swipl", "-g", "main", "-t", "halt", tmp], check=True)
    return 0


if __name__ == "__main__":
    raise SystemExit(main())