Files
rune/scripts/gen_tui_validator_truth.py
T

372 lines
14 KiB
Python

#!/usr/bin/env python3
"""gen_tui_validator_truth.py — freeze the tui_validator oracle's full
input→output surface (TUI design capture; Bubble Tea reconstruction parity).
Shared-harness freeze pattern (scripts/freeze_truth.py): exercise every
public function — and every branch-bearing private helper — of
src/rune/tui_validator.py and record {"fn", "args", "kwargs", "expected"}
rows into docs/tui-design-capture/frozen_tui_validator_truth.json. The
Go/Bubble Tea rewrite must prove parity against this file.
Why FIXTURES, not the live tree: validate_all_modules / validate_imports
read the LIVE src/rune/*.py — freezing that would rot on every repo edit.
The truth instead pins self-contained fixture trees under
scripts/tui_validator_fixtures/ (rewritten byte-identically on every run),
so the DECISION LOGIC — not today's CSS — is what's frozen. Live-tree mode
stays exercised by the CLI (`tui_validator.py check|css`) and
tests/test_tui_validator_imports.py.
Determinism pins (byte-identical regeneration):
* PYTHONHASHSEED=0 — _find_closest_property iterates the
VALID_CSS_PROPERTIES SET; an equidistant suggestion tie (e.g. 'layerz'
→ layer|layers, both edit-distance 1) otherwise depends on per-process
hash order. This script re-execs itself with the pin set.
* RUNE_SCRIPTS_DIR=scripts/tui_validator_fixtures/scripts_home — the
'scripts' import classification resolves via memory_env.scripts_dir().
* cwd pinned to the repo root — fixture paths are repo-relative, so
frozen args (and 'Could not read …' messages) are checkout-location
independent.
Usage:
python3 scripts/gen_tui_validator_truth.py # freeze
python3 scripts/gen_tui_validator_truth.py --verify # re-freeze + compare
"""
from __future__ import annotations
import hashlib
import json
import os
import sys
from pathlib import Path
REPO = Path(__file__).resolve().parents[1]
TRUTH_PATH = REPO / "docs" / "tui-design-capture" / "frozen_tui_validator_truth.json"
FIXTURE_ROOT = "scripts/tui_validator_fixtures" # repo-relative (cwd is pinned)
ORACLE = "rune.tui_validator"
FROZEN_NOW = "2026-10-14T00:00:00"
# ── deterministic pin: hash order decides suggestion ties ──────────────────
if os.environ.get("PYTHONHASHSEED") != "0":
os.environ["PYTHONHASHSEED"] = "0"
os.execv(sys.executable, [sys.executable, *sys.argv])
os.chdir(REPO)
os.environ["RUNE_SCRIPTS_DIR"] = f"{FIXTURE_ROOT}/scripts_home"
sys.path.insert(0, str(REPO / "scripts"))
sys.path.insert(0, str(REPO / "src"))
import freeze_truth # noqa: E402
# ════════════════════════════════════════════════════════════════════════════
# Fixture trees (rewritten identically on every run — freeze AND verify)
# ════════════════════════════════════════════════════════════════════════════
CLEAN_TUI = '''from __future__ import annotations
import os
import sys
from textual.app import App
from textual.widgets import Input
from textual_autocomplete import AutoComplete
import rich
import rune
from rune.tui_transport import get_transport
from .tui_backlog import list_backlog
from . import tui_transport
import memory_env
import bare_helper
DEFAULT_CSS = """
Screen {
background: $surface;
layer: base;
align: center middle;
}
"""
'''
CLEAN_BUBBLE = '''CSS = """
.bubble {
background: $panel;
border: round $primary;
margin: 0 1;
}
"""
'''
CLEAN_SPINNER = '''DEFAULT_CSS = """
.spinner {
text-style: bold;
color: $accent;
}
"""
'''
# Comma-import ('os,') + unknown import are FLAGGED; the indented import
# inside the function is INVISIBLE to the ^-anchored regex (quirk frozen).
DIRTY_TUI = '''from __future__ import annotations
import os, sys
from textual.app import App
import mystery_mod
from .tui_backlog import list_backlog
def _helper():
import indented_invisible
DEFAULT_CSS = """
#chat {
dock: bottom;
height: auto;
}
"""
'''
# backgroud (suggestion expected) + selection-background (Rich-ism, no
# suggestion possible) + Width (uppercase — silently IGNORED by the regex).
DIRTY_BUBBLE = '''CSS = """
.bubble {
backgroud: $panel;
selection-background: rgb(60, 60, 80);
Width: 100%;
}
"""
'''
# NOT in validate_all_modules' hardcoded file list — must be ignored (quirk).
OTHER_WIDGET = '''DEFAULT_CSS = """
.other {
colr: red;
}
"""
'''
GOOD_MODULE = '''"""Fixture module — docstring triple-quotes are not CSS literals."""
from __future__ import annotations
DEFAULT_CSS = """
.foo {
width: 100%;
height: 1fr;
background: $surface; /* inline comment */
}
.foo:focus {
border: solid $primary;
padding: 1 2;
margin: 0 1;
}
"""
'''
# Two REAL literals (DEFAULT_CSS, CSS) are validated; the _ALT name, the
# f-prefixed literal, and the single-quoted triple are all IGNORED (quirks).
BAD_MODULE = '''DEFAULT_CSS = """
.a {
backgroud: red;
}
"""
CSS = """
.b {
colr: blue;
}
"""
DEFAULT_CSS_ALT = """
.c {
zzz: 1;
}
"""
CSS = f"""f-prefix trap: zzzz: 2;"""
SINGLE = \'\'\'
.e {
zzzzz: 3;
}
\'\'\'
'''
FIXTURES: dict[str, str] = {
f"{FIXTURE_ROOT}/scripts_home/bare_helper.py": "# stub — a legacy scripts-dir module\n",
f"{FIXTURE_ROOT}/tree_clean/src/rune/__init__.py": "",
f"{FIXTURE_ROOT}/tree_clean/src/rune/memory_env.py": "# stub — first-party via directory listing\n",
f"{FIXTURE_ROOT}/tree_clean/src/rune/rune_tui.py": CLEAN_TUI,
f"{FIXTURE_ROOT}/tree_clean/src/rune/chat_bubble.py": CLEAN_BUBBLE,
f"{FIXTURE_ROOT}/tree_clean/src/rune/rune_spinner.py": CLEAN_SPINNER,
f"{FIXTURE_ROOT}/tree_dirty/src/rune/__init__.py": "",
f"{FIXTURE_ROOT}/tree_dirty/src/rune/memory_env.py": "# stub — first-party via directory listing\n",
f"{FIXTURE_ROOT}/tree_dirty/src/rune/rune_tui.py": DIRTY_TUI,
f"{FIXTURE_ROOT}/tree_dirty/src/rune/chat_bubble.py": DIRTY_BUBBLE,
f"{FIXTURE_ROOT}/tree_dirty/src/rune/rune_spinner.py": CLEAN_SPINNER,
f"{FIXTURE_ROOT}/tree_dirty/src/rune/other_widget.py": OTHER_WIDGET,
f"{FIXTURE_ROOT}/tree_no_tui/src/rune/chat_bubble.py": CLEAN_BUBBLE,
f"{FIXTURE_ROOT}/mod_css_good.py": GOOD_MODULE,
f"{FIXTURE_ROOT}/mod_css_bad.py": BAD_MODULE,
}
CLEAN_TREE = f"{FIXTURE_ROOT}/tree_clean"
DIRTY_TREE = f"{FIXTURE_ROOT}/tree_dirty"
NO_TUI_TREE = f"{FIXTURE_ROOT}/tree_no_tui"
CLEAN_SRC = f"{CLEAN_TREE}/src/rune"
def write_fixtures() -> None:
for rel, content in sorted(FIXTURES.items()):
path = REPO / rel
path.parent.mkdir(parents=True, exist_ok=True)
path.write_text(content)
# ════════════════════════════════════════════════════════════════════════════
# Case battery — every branch of the decision surface
# ════════════════════════════════════════════════════════════════════════════
def cases() -> list[tuple]:
c: list[tuple] = []
# ── extract_css_properties: line-level decision table ──────────────────
extract = [
("",),
(".foo {\n width: 100%;\n}\n",),
("/* header\n spanning */\nwidth: 1\n",), # line-shift after comment strip
("width: 1 /* trailing */\n",),
("#id {\n* star\n&:hover {\n@media screen\n{\n}\n",), # all selector/comment forms skipped
("Width: 10\n",), # uppercase first char: dropped
("wIdth: 10\n",), # mixed case: dropped
("width:1\n",),
("width : 1\n",),
("grid-size: 3 3\n",),
(".a {\n width: 1;\n height: 2;\n hatch: /;\n}\n",),
("color: red; border: solid\n",), # only the FIRST prop per line
("width: 1\r\nheight: 2\r\n",), # CRLF tolerated
("3d-depth: 1\n",), # digit-led: dropped
("background: url(data:image/png;base64,AAA)\n",), # colons inside the value
("-webkit-transform: none\n",), # leading hyphen: dropped
]
for css in extract:
c.append(("extract_css_properties", css, {}))
# ── validate_css: property verdict table ───────────────────────────────
validate = [
(""".foo {
width: 100%;
height: 1fr;
background: $surface;
border: solid $primary;
padding: 1 2;
margin: 0 1;
}
""", "selftest1-valid"),
(""".foo {
width: 100%;
selection-background: rgb(60, 60, 80);
selection-foreground: $text;
}
""", "selftest2-richisms"),
(".foo {\n backgroud: red;\n}", "suggestion-dist1"),
(".a {\n layerz: 1;\n}", {}), # TIE: layer|layers both dist 1 (hash pin)
(".a {\n zzzzzz: 1;\n}", "no-suggestion-far"),
(".a {\n bor: 1;\n}", "no-suggestion-dist3"), # dist 3 is NOT < 3
(".a {\n Width: 100%;\n}", "uppercase-escape"), # silently valid (quirk)
(".a {\n zzzz: 1;\n}", "mymod:L5"), # source label pass-through
(".a {\n colr: 1;\n colr: 2;\n}\n", "dup-lines"), # same bad prop on two lines
(".a {\n width: nonsense-value;\n}", "values-unchecked"),
(".foo:focus {\n border: solid;\n}", "pseudo-selector"),
("/*\ncolr: red\n*/\nwidth: 1\n", "comment-swallow"), # prop inside comment: stripped, no error
(".a {\n hatch: -;\n keyline: heavy;\n scrollbar-corner-color: red;\n text-opacity: 0.5;\n}\n", "exotic-valid"),
("", "empty"),
]
for css, source in validate:
if isinstance(source, dict):
c.append(("validate_css", (css,), source))
else:
c.append(("validate_css", (css, source), {}))
# ── _find_closest_property / _edit_distance: suggestion engine ────────
for prop in ("backgroud", "layerz", "zzzzzz", "border", "", "colr"):
c.append(("_find_closest_property", (prop,), {}))
for a, b in [
("", ""), ("a", ""), ("", "abc"), ("same", "same"),
("kitten", "sitting"), ("backgroud", "background"),
("layerz", "layer"), ("layerz", "layers"),
("abc", "yabd"), ("flaw", "lawn"), ("intention", "execution"),
]:
c.append(("_edit_distance", (a, b), {}))
# ── _classify: import verdict table ────────────────────────────────────
for module_name in [
".", ".sibling", # relative
"os", "__future__", "sys.path", # stdlib (incl. dotted top)
"textual", "textual.widgets", "textual_autocomplete", "rich", # external allowlist
"rune", "rune.tui_transport", # first-party: package basename
"memory_env", # first-party: directory listing
"bare_helper", # scripts dir (RUNE_SCRIPTS_DIR fixture)
"mystery_mod", # unknown
"os,", # comma-capture quirk: trailing comma
]:
c.append(("_classify", (module_name, CLEAN_SRC), {}))
# ── validate_module_css: literal-harvest decision table ────────────────
for path in [
f"{FIXTURE_ROOT}/mod_css_good.py",
f"{FIXTURE_ROOT}/mod_css_bad.py",
f"{CLEAN_TREE}/src/rune/rune_tui.py",
f"{DIRTY_TREE}/src/rune/chat_bubble.py",
f"{FIXTURE_ROOT}/missing.py", # unreadable → single degrade message
]:
c.append(("validate_module_css", (path,), {}))
# ── tree-level scanners ────────────────────────────────────────────────
for fn in ("validate_imports", "validate_all_modules", "validate_composition", "run_full_validation"):
for tree in (CLEAN_TREE, DIRTY_TREE, NO_TUI_TREE):
if fn in ("validate_composition",) and tree == NO_TUI_TREE:
continue # stub — two trees prove the constant []
c.append((fn, (tree,), {}))
return c
def main() -> int:
write_fixtures()
if "--verify" in sys.argv:
return freeze_truth.verify(TRUTH_PATH, ORACLE)
battery = cases()
truth = freeze_truth.freeze(ORACLE, battery, oracle_module=ORACLE)
doc = {
"frozen_now": FROZEN_NOW,
"generator": "scripts/gen_tui_validator_truth.py",
"oracle": "src/rune/tui_validator.py",
"python": ".".join(map(str, sys.version_info[:3])),
"pins": {
"PYTHONHASHSEED": "0 (suggestion ties iterate the VALID_CSS_PROPERTIES set)",
"RUNE_SCRIPTS_DIR": f"{FIXTURE_ROOT}/scripts_home",
"cwd": "repo root (all fixture paths repo-relative)",
},
"note": (
"Frozen via the shared harness (scripts/freeze_truth.py) against fixture "
"trees under scripts/tui_validator_fixtures/ — the decision logic is the "
"parity surface, not the live tree's current CSS. Verify: "
"python3 scripts/gen_tui_validator_truth.py --verify"
),
"cases": truth,
}
TRUTH_PATH.parent.mkdir(parents=True, exist_ok=True)
TRUTH_PATH.write_text(json.dumps(doc, indent=2, ensure_ascii=False, sort_keys=False) + "\n")
by_fn: dict[str, int] = {}
for row in truth:
by_fn[row["fn"]] = by_fn.get(row["fn"], 0) + 1
for fn, n in by_fn.items():
print(f" {fn}: {n}")
digest = hashlib.sha256(TRUTH_PATH.read_bytes()).hexdigest()
print(f"froze {len(truth)} rows -> {TRUTH_PATH.relative_to(REPO)}")
print(f"sha256 {digest}")
return 0
if __name__ == "__main__":
sys.exit(main())