scripts.derive_test_categories
Reference › scripts › derive_test_categories
Derives a category for each test module from its syntax tree: the RUL-039 / RUL-036#13 pass. It writes the result as a committed artifact. tests/scripts/test_derive_test_categories.py imports it.
Invocation: main with no options derives the categories and writes the artifact. --stdout prints a human table instead of writing. --check recomputes and compares with the committed artifact, and returns 2 if the artifact is missing or unreadable, if its derivation version differs from the script's, or if the stored fingerprint of the tests tree no longer matches, with a message telling the user to re-derive.
derive goes through the test modules (_test_modules) and calls _derive_markers on each. That function parses the module and looks for evidence of its category: explicit pytest marker names (_pytest_marker_names), subprocess calls and the command words in their arguments (_is_subprocess_call, _argv_strings, _tokenize), and other string constants. _body_hash and _fingerprint hash the modules and the whole set, and the fingerprint is what --check compares, so any change to the tests tree makes the artifact stale.
At a glance
| Source | scripts/derive_test_categories.py |
| Lines of code | 434 |
| Classes | 0 |
| Functions | 14 |
| Imports (internal) | none |
| Imported by | none |
| Tests that import it | tests/scripts/test_derive_test_categories.py |
Class diagram
No classes; see Functions.
Dependency diagram
No internal imports and no importers.
API
Functions
| Function | Signature | Summary |
|---|---|---|
_lineno |
_lineno(node: ast.AST) -> int |
|
_is_subprocess_call |
_is_subprocess_call(node: ast.AST) -> bool |
True for subprocess.run(...) / os.system(...) etc. |
_argv_strings |
_argv_strings(node: ast.AST) -> list[str] |
The string literals in a subprocess call's argv (first positional arg as a list, or the whole string for os.system). |
_string_constants |
_string_constants(node: ast.AST) -> list[str] |
Every string constant in a subtree (for env-key guard detection). |
_tokenize |
_tokenize(s: str) -> set[str] |
Execution-position tokens in an argv string: the executable basename and any bare alphanumeric word. |
_pytest_marker_names |
_pytest_marker_names(tree: ast.AST) -> list[str] |
Names of @pytest.mark.<name> decorators on any node. |
_derive_markers |
_derive_markers(module: Path, body: str) -> list[dict] |
The rehearsal markers present in one test module, with file:line evidence. |
_test_modules |
_test_modules() -> list[Path] |
|
_body_hash |
_body_hash(body: str) -> str |
|
_fingerprint |
_fingerprint(rows: list[dict]) -> str |
The tests-tree fingerprint: sha of the sorted (module, body_hash) list. |
derive |
derive() -> dict |
|
_load_artifact |
_load_artifact() -> dict | None |
|
_stdout_table |
_stdout_table(data: dict) -> None |
|
main |
main(argv: list[str] | None = None) -> int |
Source
#!/usr/bin/env python3
"""Derive test-module categories from AST — the RUL-039 / RUL-036#13 pass.
Computes, per test module under ``tests/``, a category row. This pass derives ONE
class: ``rehearsal``. The category is COMPUTED from what a test actually does —
never hand-set (RUL-036's own words) — so a label always carries its file:line
evidence; a label without evidence is invalid.
The rehearsal class (the founding incident — dead run 906142, ``docs/lessons/
L23-exoneration.md``) is the set of tests exercising real deploy surfaces:
- subprocess/CLI argv string literals whose tokens include ``incus``, ``lxc``,
``docker``, ``terraform``, ``tofu`` (the RUL-038/ADR-0111 marker set) in
EXECUTION position — not prose, not docstrings, not asserted-against data;
- env-gated live lanes: guards on ``INCUS_HARNESS_IT``-style live-run env keys,
and ``@pytest.mark.<...>_live`` / ``@pytest.mark.live`` markers;
- real deploy-surface invocations: subprocess calls into ``deploy/``, ``ship``,
``promote`` or the installer scripts (the mechanism, not a directory name).
Deliberately NOT markers: importing a deploy module to unit-test its pure
functions; ``tmp_path`` fixture deploy configs; the word "deploy" in test names
or docstrings.
Schema (designed to EXTEND, not be replaced):
.. code-block:: json
{"derivation_version": 1,
"generated_at": "...",
"tests_tree_fingerprint": "<sha of sorted (module, body_hash)>",
"rows": [{"module": "tests/harness/test_incus_isolation.py",
"category": "rehearsal",
"markers": [{"kind": "argv-literal", "value": "incus", "at": "f:l"}, ...]}]}
``--check`` recomputes and compares against the committed artifact, exiting
non-zero on drift (the staleness gate consumes this). ``--stdout`` prints a
human table. Default: write ``reports/rehearsal-class-modules.json``.
"""
from __future__ import annotations
import argparse
import ast
import hashlib
import json
import sys
from datetime import UTC, datetime
from pathlib import Path
REPO_ROOT = Path(__file__).resolve().parent.parent
ARTIFACT = REPO_ROOT / "reports" / "rehearsal-class-modules.json"
DERIVATION_VERSION = 1
#: subprocess/CLI argv tokens whose presence in EXECUTION position marks the
#: RUL-038/ADR-0111 rehearsal marker set (container + IaC surfaces).
_ARGV_TOKENS = ("incus", "lxc", "docker", "terraform", "tofu")
#: Live-run env keys — a guard on one of these is an env-gated live lane.
_LIVE_ENV_KEYS = ("INCUS_HARNESS_IT",)
#: Deploy-surface argv references — invoking one of the repo's deploy scripts is
#: a real deploy surface. Script names (with ``.sh``) are unambiguous, unlike a
#: bare "ship"/"deploy" word.
_DEPLOY_SURFACE = (
"deploy.sh",
"install-claude-config.sh",
"install-gitlab-runner-executor.sh",
"install-orchestrata.sh",
"install-tools.sh",
"provision_environments.sh",
"restart-tool-instances.sh",
"retire-plain-units.sh",
"ship-merge-master.sh",
"ship.sh",
"toolchain.sh",
)
#: subprocess/CLI entry points whose argv we inspect.
_SUBPROCESS_ATTRS = {
"subprocess.run",
"subprocess.Popen",
"subprocess.call",
"subprocess.check_call",
"subprocess.check_output",
"os.system",
"os.popen",
}
def _lineno(node: ast.AST) -> int:
return getattr(node, "lineno", 0)
def _is_subprocess_call(node: ast.AST) -> bool:
"""True for ``subprocess.run(...)`` / ``os.system(...)`` etc."""
if not isinstance(node, ast.Call):
return False
func = node.func
if isinstance(func, ast.Attribute):
if isinstance(func.value, ast.Name):
return f"{func.value.id}.{func.attr}" in _SUBPROCESS_ATTRS
return False
def _argv_strings(node: ast.AST) -> list[str]:
"""The string literals in a subprocess call's argv (first positional arg
as a list, or the whole string for ``os.system``). Execution position only."""
if not isinstance(node, ast.Call):
return []
args = node.args
if not args:
return []
first = args[0]
out: list[str] = []
if isinstance(first, ast.List):
for elt in first.elts:
if isinstance(elt, ast.Constant) and isinstance(elt.value, str):
out.append(elt.value)
elif isinstance(first, ast.Constant) and isinstance(first.value, str):
out.append(first.value)
return out
def _string_constants(node: ast.AST) -> list[str]:
"""Every string constant in a subtree (for env-key guard detection)."""
out: list[str] = []
for child in ast.walk(node):
if isinstance(child, ast.Constant) and isinstance(child.value, str):
out.append(child.value)
return out
def _tokenize(s: str) -> set[str]:
"""Execution-position tokens in an argv string: the executable basename and
any bare alphanumeric word. A flag (``--incus-json``) or a filename
(``incus.json``) contributes no bare token — only an executable named
``incus`` or a standalone ``incus`` word does."""
tokens: set[str] = set()
words = s.split()
if not words:
return tokens
tokens.add(words[0].rsplit("/", 1)[-1])
for w in words:
if w.isalnum():
tokens.add(w)
return tokens
def _pytest_marker_names(tree: ast.AST) -> list[str]:
"""Names of ``@pytest.mark.<name>`` decorators on any node."""
names: list[str] = []
for node in ast.walk(tree):
for dec in getattr(node, "decorator_list", []):
if (
isinstance(dec, ast.Attribute)
and isinstance(dec.value, ast.Attribute)
and isinstance(dec.value.value, ast.Name)
and dec.value.value.id == "pytest"
and dec.value.attr == "mark"
):
names.append(dec.attr)
return names
def _derive_markers(module: Path, body: str) -> list[dict]:
"""The rehearsal markers present in one test module, with file:line evidence.
Empty list == not rehearsal (the only category this pass derives)."""
try:
tree = ast.parse(body, filename=str(module))
except SyntaxError:
# An unparsable test module cannot be categorized — but the suite
# itself refuses to collect it, so it never reaches the gate. Skip.
return []
markers: list[dict] = []
# 1. argv-literal tokens (incus/lxc/docker/terraform/tofu) in subprocess argv.
# Match whole-word TOKENS, not substrings of flags/filenames: "incus" in
# "--incus-json" or "incus.json" is a CLI/fixture reference, not a container
# surface.
argv_tokens: set[str] = set()
for node in ast.walk(tree):
if _is_subprocess_call(node):
for s in _argv_strings(node):
for word in _tokenize(s):
if word in _ARGV_TOKENS:
argv_tokens.add(word)
for tok in sorted(argv_tokens):
at = ""
for node in ast.walk(tree):
if _is_subprocess_call(node):
if any(tok in _tokenize(s) for s in _argv_strings(node)):
at = f"{module}:{_lineno(node)}"
break
markers.append({"kind": "argv-literal", "value": tok, "at": at})
# 2. env-gated live lanes (INCUS_HARNESS_IT-style guards).
for key in _LIVE_ENV_KEYS:
for node in ast.walk(tree):
found = False
if isinstance(node, ast.Call):
# os.environ.get("KEY")
func = node.func
if (
isinstance(func, ast.Attribute)
and isinstance(func.value, ast.Attribute)
and isinstance(func.value.value, ast.Name)
and func.value.value.id == "os"
and func.value.attr == "environ"
):
found = any(key == c for c in _string_constants(node))
elif isinstance(node, ast.Subscript):
# os.environ["KEY"]
value = node.value
if (
isinstance(value, ast.Attribute)
and isinstance(value.value, ast.Name)
and value.value.id == "os"
and value.attr == "environ"
):
found = any(key == c for c in _string_constants(node))
if found:
markers.append(
{
"kind": "env-gated",
"value": key,
"at": f"{module}:{_lineno(node)}",
}
)
break
# 3. pytest live markers (@pytest.mark.incus_live / *_live / live).
for name in sorted(set(_pytest_marker_names(tree))):
if name == "live" or name.endswith("_live") or "incus" in name:
markers.append(
{
"kind": "pytest-marker",
"value": name,
"at": f"{module}:{_lineno(tree)}",
}
)
# 4. deploy-surface invocations — a subprocess call that reaches a deploy
# script, either directly (argv literal names the script) or via a variable
# assigned to a deploy path (``SHIP = REPO_ROOT / "deploy" / "ship.sh"``
# then ``subprocess.run(["bash", str(SHIP), ...])``). Referencing a deploy
# script as *data* (a fixture input to some other tool) is NOT a marker —
# the subprocess must actually reach the script.
deploy_refs: set[str] = set()
# 4a. direct: the deploy script is the executable (argv[0]) or the script
# argument to a shell interpreter (argv[1] when argv[0] is bash/sh). A deploy
# script passed as *data* to another tool (argv[1] of `python other_tool.py`)
# is NOT a marker.
_SHELLS = ("bash", "sh", "/bin/bash", "/bin/sh", "bash.exe")
for node in ast.walk(tree):
if not _is_subprocess_call(node):
continue
argv = _argv_strings(node)
if not argv:
continue
positions = [argv[0]]
if argv[0].rsplit("/", 1)[-1] in _SHELLS and len(argv) > 1:
positions.append(argv[1])
for s in positions:
for surf in _DEPLOY_SURFACE:
if surf in s:
deploy_refs.add(surf)
# 4b. indirect: a Name assigned to a deploy-path expression is referenced
# by a subprocess argv (str(SHIP) etc.).
for node in ast.walk(tree):
targets: list[ast.Name] = []
value = None
if isinstance(node, ast.Assign):
targets = [t for t in node.targets if isinstance(t, ast.Name)]
value = node.value
elif isinstance(node, ast.AnnAssign):
targets = [node.target] if isinstance(node.target, ast.Name) else []
value = node.value
if value is None or not targets:
continue
matched = {
surf
for c in _string_constants(value)
for surf in _DEPLOY_SURFACE
if surf in c
}
if not matched:
continue
names = {t.id for t in targets}
for sub in ast.walk(tree):
if not _is_subprocess_call(sub):
continue
first = sub.args[0] if sub.args else None
if first is not None and any(
isinstance(n, ast.Name) and n.id in names for n in ast.walk(first)
):
deploy_refs |= matched
for surf in sorted(deploy_refs):
at = ""
for node in ast.walk(tree):
if _is_subprocess_call(node):
at = f"{module}:{_lineno(node)}"
break
markers.append({"kind": "deploy-surface", "value": surf, "at": at})
return markers
def _test_modules() -> list[Path]:
return sorted(REPO_ROOT.glob("tests/**/test_*.py"))
def _body_hash(body: str) -> str:
return hashlib.sha256(body.encode("utf-8")).hexdigest()
def _fingerprint(rows: list[dict]) -> str:
"""The tests-tree fingerprint: sha of the sorted (module, body_hash) list."""
parts: list[str] = []
for m in _test_modules():
try:
body = m.read_text(encoding="utf-8")
except OSError:
continue
parts.append(f"{m.relative_to(REPO_ROOT)}\0{_body_hash(body)}")
digest = hashlib.sha256("\n".join(parts).encode("utf-8")).hexdigest()
return digest
def derive() -> dict:
rows: list[dict] = []
for m in _test_modules():
try:
body = m.read_text(encoding="utf-8")
except OSError:
continue
markers = _derive_markers(m, body)
if not markers:
continue
rows.append(
{
"module": str(m.relative_to(REPO_ROOT)),
"category": "rehearsal",
"markers": markers,
}
)
return {
"derivation_version": DERIVATION_VERSION,
"generated_at": datetime.now(UTC).isoformat(),
"tests_tree_fingerprint": _fingerprint(rows),
"rows": rows,
}
def _load_artifact() -> dict | None:
if not ARTIFACT.is_file():
return None
try:
return json.loads(ARTIFACT.read_text(encoding="utf-8"))
except OSError, json.JSONDecodeError:
return None
def _stdout_table(data: dict) -> None:
print(f"# rehearsal-class modules ({len(data['rows'])}):")
for row in data["rows"]:
kinds = ", ".join(f"{m['kind']}:{m['value']}" for m in row["markers"])
print(f" {row['module']} — {kinds}")
def main(argv: list[str] | None = None) -> int:
parser = argparse.ArgumentParser(description=__doc__)
parser.add_argument(
"--check",
action="store_true",
help="recompute and compare against the committed artifact; exit non-zero on drift",
)
parser.add_argument(
"--stdout",
action="store_true",
help="print a human table instead of writing the artifact",
)
args = parser.parse_args(argv)
data = derive()
if args.stdout:
_stdout_table(data)
return 0
if args.check:
artifact = _load_artifact()
if artifact is None:
print(
"rehearsal-class: artifact missing or unparsable — regenerate "
"with `python scripts/derive_test_categories.py`",
file=sys.stderr,
)
return 2
if artifact.get("derivation_version") != DERIVATION_VERSION:
print(
f"rehearsal-class: derivation_version mismatch "
f"({artifact.get('derivation_version')} != {DERIVATION_VERSION})",
file=sys.stderr,
)
return 2
if artifact.get("tests_tree_fingerprint") != data["tests_tree_fingerprint"]:
print(
"rehearsal-class: tests_tree_fingerprint mismatch — the tree "
"changed since the artifact was derived. Re-derive with "
"`python scripts/derive_test_categories.py`",
file=sys.stderr,
)
return 2
if artifact.get("rows") != data["rows"]:
print(
"rehearsal-class: rows drift — regenerate with "
"`python scripts/derive_test_categories.py`",
file=sys.stderr,
)
return 2
print("rehearsal-class: artifact == tree ✅")
return 0
ARTIFACT.write_text(json.dumps(data, indent=2) + "\n", encoding="utf-8")
print(f"wrote {ARTIFACT} ({len(data['rows'])} rehearsal-class modules)")
return 0
if __name__ == "__main__":
raise SystemExit(main())