feat(harness): persist CTE preview rows, add tht cte info, --doc for cte plan

WS1 of review-gates-v2: gives the JS gate (WS2) deterministic data to build the
cte_result v2 payload.

- CteTestRecord gains optional preview_rows (JSON-coerced, truncated cells);
  test_cmd populates it from the bounded result rows.
- New read-only `tht cte info <name> --session <id> [--json]`: plan
  index/total, persisted .sql, cte_plan_doc.json entry (if any), last
  CteTestRecord. Exits 1 with a clean stderr message on missing
  session/plan/name/sql.
- `tht cte plan --doc -` validates a chain-doc JSON (ctes[].name must match
  --name, same order) and writes it to cte_plan_doc.json; cte_plan.json stays
  a plain list[str] (load-bearing for tht.phase.next_cte). --doc is optional.
This commit is contained in:
2026-07-07 00:23:50 +02:00
parent 7491e8c3ac
commit 9b4f6b9804
5 changed files with 453 additions and 3 deletions
+149
View File
@@ -0,0 +1,149 @@
"""L1: `tht cte info <name>` — read-only CTE detail (plan position + sql + doc + last test).
Feeds the JS gate (WS2) building the cte_result v2 payload: it needs index/total from
cte_plan.json (same source as tht.phase.next_cte), the persisted .sql file, the optional
cte_plan_doc.json entry, and the last CteTestRecord for the CTE. --json output must be
pristine (only valid JSON on stdout).
"""
import json
from datetime import datetime
from typer.testing import CliRunner
from tht.cli.cte_cmd import cte_app
from tht.config import DatabaseConfig
from tht.ctetest import CteTestRecord, append_cte_test
from tht.decisions import append_decision
from tht.session.store import create_session
def _db():
return DatabaseConfig(database="testdb", user="u", password="p", **{"schema": "public"}) # noqa: S106
def _patch_cfg(monkeypatch, tmp_path):
from tht.cli import cte_cmd
class FakePaths:
sessions = tmp_path
class FakeCfg:
paths = FakePaths()
database = _db()
monkeypatch.setattr(cte_cmd, "_load_config_or_exit", lambda _: FakeCfg())
def _make_session(tmp_path, plan):
m = create_session("q", _db(), tmp_path)
sdir = tmp_path / m.id
(sdir / "cte_plan.json").write_text(json.dumps(plan))
(sdir / "ctes").mkdir(parents=True, exist_ok=True)
return m.id, sdir
def test_info_happy_path_json(tmp_path, monkeypatch):
sid, sdir = _make_session(tmp_path, ["a", "b"])
(sdir / "ctes" / "b.sql").write_text("WITH b AS (SELECT 1)")
append_cte_test(sdir, CteTestRecord(
name="b", ts=datetime(2025, 1, 1, 12, 0), sql_hash="h1", status="ok",
columns=["x"], row_sample=1, execution_ms=5, preview_rows=[[1]],
))
_patch_cfg(monkeypatch, tmp_path)
res = CliRunner().invoke(cte_app, ["info", "b", "--session", sid, "--json"])
assert res.exit_code == 0, res.output
data = json.loads(res.output)
assert data["name"] == "b"
assert data["index"] == 2
assert data["total"] == 2
assert data["plan"] == ["a", "b"]
assert data["sql"] == "WITH b AS (SELECT 1)"
assert data["approved"] is False
assert data["doc"] is None
assert data["last_test"]["sql_hash"] == "h1"
assert data["last_test"]["preview_rows"] == [[1]]
def test_info_approved_true_when_decision_present(tmp_path, monkeypatch):
sid, sdir = _make_session(tmp_path, ["a"])
(sdir / "ctes" / "a.sql").write_text("WITH a AS (SELECT 1)")
append_decision(sdir, type="cte_approved", subject="a")
_patch_cfg(monkeypatch, tmp_path)
res = CliRunner().invoke(cte_app, ["info", "a", "--session", sid, "--json"])
assert res.exit_code == 0, res.output
data = json.loads(res.output)
assert data["approved"] is True
def test_info_doc_present(tmp_path, monkeypatch):
sid, sdir = _make_session(tmp_path, ["a"])
(sdir / "ctes" / "a.sql").write_text("WITH a AS (SELECT 1)")
doc = {"ctes": [{"name": "a", "purpose": "p", "rationale": "r", "depends_on": []}]}
(sdir / "cte_plan_doc.json").write_text(json.dumps(doc, ensure_ascii=False))
_patch_cfg(monkeypatch, tmp_path)
res = CliRunner().invoke(cte_app, ["info", "a", "--session", sid, "--json"])
assert res.exit_code == 0, res.output
data = json.loads(res.output)
assert data["doc"] == {"purpose": "p", "rationale": "r", "depends_on": []}
def test_info_last_test_none_when_no_records(tmp_path, monkeypatch):
sid, sdir = _make_session(tmp_path, ["a"])
(sdir / "ctes" / "a.sql").write_text("WITH a AS (SELECT 1)")
_patch_cfg(monkeypatch, tmp_path)
res = CliRunner().invoke(cte_app, ["info", "a", "--session", sid, "--json"])
assert res.exit_code == 0, res.output
data = json.loads(res.output)
assert data["last_test"] is None
def test_info_last_test_is_most_recent_record(tmp_path, monkeypatch):
sid, sdir = _make_session(tmp_path, ["a"])
(sdir / "ctes" / "a.sql").write_text("WITH a AS (SELECT 1)")
append_cte_test(sdir, CteTestRecord(
name="a", ts=datetime(2025, 1, 1), sql_hash="old", status="ok",
))
append_cte_test(sdir, CteTestRecord(
name="a", ts=datetime(2025, 1, 2), sql_hash="new", status="ok",
))
_patch_cfg(monkeypatch, tmp_path)
res = CliRunner().invoke(cte_app, ["info", "a", "--session", sid, "--json"])
assert res.exit_code == 0, res.output
data = json.loads(res.output)
assert data["last_test"]["sql_hash"] == "new"
def test_info_nonexistent_session_exit_1(tmp_path, monkeypatch):
_patch_cfg(monkeypatch, tmp_path)
res = CliRunner().invoke(cte_app, ["info", "a", "--session", "nope", "--json"])
assert res.exit_code == 1
assert res.stdout == "" # stdout stays pristine JSON-or-nothing
def test_info_missing_plan_exit_1(tmp_path, monkeypatch):
m = create_session("q", _db(), tmp_path)
_patch_cfg(monkeypatch, tmp_path)
res = CliRunner().invoke(cte_app, ["info", "a", "--session", m.id, "--json"])
assert res.exit_code == 1
assert res.stdout == ""
def test_info_name_not_in_plan_exit_1(tmp_path, monkeypatch):
sid, sdir = _make_session(tmp_path, ["a"])
_patch_cfg(monkeypatch, tmp_path)
res = CliRunner().invoke(cte_app, ["info", "zzz", "--session", sid, "--json"])
assert res.exit_code == 1
assert res.stdout == ""
def test_info_missing_sql_file_exit_1(tmp_path, monkeypatch):
sid, sdir = _make_session(tmp_path, ["a"])
_patch_cfg(monkeypatch, tmp_path)
res = CliRunner().invoke(cte_app, ["info", "a", "--session", sid, "--json"])
assert res.exit_code == 1
assert res.stdout == ""
+119
View File
@@ -0,0 +1,119 @@
"""L1: `tht cte plan --doc -` — persists the cte_plan_doc.json chain documentation.
cte_plan.json (list[str], load-bearing for tht.phase.next_cte) stays untouched; the
doc (payload-A shape: {"ctes": [{"name", "purpose", "rationale", "depends_on", ...}]})
is validated (names must match --name, same order) and written verbatim to a
separate sessions/<id>/cte_plan_doc.json. --doc is optional; without it, behavior
is unchanged (existing test_cte_next.py covers the no-doc path).
"""
import json
from typer.testing import CliRunner
from tht.cli.cte_cmd import cte_app
from tht.config import DatabaseConfig
from tht.decisions import append_decision
from tht.session.store import create_session
def _db():
return DatabaseConfig(database="testdb", user="u", password="p", **{"schema": "public"}) # noqa: S106
def _patch_cfg(monkeypatch, tmp_path):
from tht.cli import cte_cmd
class FakePaths:
sessions = tmp_path
class FakeCfg:
paths = FakePaths()
database = _db()
monkeypatch.setattr(cte_cmd, "_load_config_or_exit", lambda _: FakeCfg())
def _advance_to_phase_6(sdir):
"""plan_cmd requires phase >= 6: approve phases 1..5 in order."""
for n in range(1, 6):
append_decision(sdir, type="phase_approved", subject=f"phase:{n}")
def test_plan_with_matching_doc_writes_cte_plan_doc_json(tmp_path, monkeypatch):
m = create_session("q", _db(), tmp_path)
_advance_to_phase_6(tmp_path / m.id)
_patch_cfg(monkeypatch, tmp_path)
doc = {
"question": "domanda riscritta",
"strategy": "razionale",
"ctes": [
{"name": "a", "purpose": "p_a", "rationale": "r_a", "depends_on": []},
{"name": "b", "purpose": "p_b", "rationale": "r_b", "depends_on": ["a"]},
],
}
res = CliRunner().invoke(
cte_app,
["plan", "--session", m.id, "--name", "a", "--name", "b", "--doc", "-"],
input=json.dumps(doc),
)
assert res.exit_code == 0, res.output
plan_path = tmp_path / m.id / "cte_plan.json"
assert json.loads(plan_path.read_text()) == ["a", "b"]
doc_path = tmp_path / m.id / "cte_plan_doc.json"
assert json.loads(doc_path.read_text()) == doc
def test_plan_without_doc_unchanged(tmp_path, monkeypatch):
m = create_session("q", _db(), tmp_path)
_advance_to_phase_6(tmp_path / m.id)
_patch_cfg(monkeypatch, tmp_path)
res = CliRunner().invoke(cte_app, ["plan", "--session", m.id, "--name", "a"])
assert res.exit_code == 0, res.output
assert json.loads((tmp_path / m.id / "cte_plan.json").read_text()) == ["a"]
assert not (tmp_path / m.id / "cte_plan_doc.json").exists()
def test_plan_doc_name_mismatch_rejected(tmp_path, monkeypatch):
m = create_session("q", _db(), tmp_path)
_advance_to_phase_6(tmp_path / m.id)
_patch_cfg(monkeypatch, tmp_path)
doc = {"ctes": [{"name": "a", "purpose": "p"}, {"name": "zzz", "purpose": "p2"}]}
res = CliRunner().invoke(
cte_app,
["plan", "--session", m.id, "--name", "a", "--name", "b", "--doc", "-"],
input=json.dumps(doc),
)
assert res.exit_code == 1
assert res.stdout == ""
# neither file should be written on a rejected doc
assert not (tmp_path / m.id / "cte_plan.json").exists()
assert not (tmp_path / m.id / "cte_plan_doc.json").exists()
def test_plan_doc_wrong_order_rejected(tmp_path, monkeypatch):
m = create_session("q", _db(), tmp_path)
_advance_to_phase_6(tmp_path / m.id)
_patch_cfg(monkeypatch, tmp_path)
doc = {"ctes": [{"name": "b", "purpose": "p"}, {"name": "a", "purpose": "p2"}]}
res = CliRunner().invoke(
cte_app,
["plan", "--session", m.id, "--name", "a", "--name", "b", "--doc", "-"],
input=json.dumps(doc),
)
assert res.exit_code == 1
assert not (tmp_path / m.id / "cte_plan_doc.json").exists()
def test_plan_doc_invalid_json_rejected(tmp_path, monkeypatch):
m = create_session("q", _db(), tmp_path)
_advance_to_phase_6(tmp_path / m.id)
_patch_cfg(monkeypatch, tmp_path)
res = CliRunner().invoke(
cte_app,
["plan", "--session", m.id, "--name", "a", "--doc", "-"],
input="{not valid json",
)
assert res.exit_code == 1
assert not (tmp_path / m.id / "cte_plan_doc.json").exists()
+60 -1
View File
@@ -7,7 +7,8 @@ and the ledger I/O (load/append, tolerant of JSON-array and JSONL formats).
Pure logic, no DB. Pure logic, no DB.
""" """
import json import json
from datetime import datetime from datetime import date, datetime
from decimal import Decimal
from pathlib import Path from pathlib import Path
import pytest import pytest
@@ -15,6 +16,7 @@ import pytest
from tht.ctetest import ( from tht.ctetest import (
CteError, CteError,
CteTestRecord, CteTestRecord,
_jsonable,
append_cte_test, append_cte_test,
build_test_sql, build_test_sql,
has_trailing_select, has_trailing_select,
@@ -131,3 +133,60 @@ def test_corrupt_ledger_raises(tmp_path):
(tmp_path / "cte_tests.json").write_text("{not valid json") (tmp_path / "cte_tests.json").write_text("{not valid json")
with pytest.raises(CteError, match="corrotto"): with pytest.raises(CteError, match="corrotto"):
load_cte_tests(tmp_path) load_cte_tests(tmp_path)
# --- _jsonable ----------------------------------------------------------
def test_jsonable_passes_through_native_types():
assert _jsonable(None) is None
assert _jsonable(True) is True
assert _jsonable(1) == 1
assert _jsonable(1.5) == 1.5
assert _jsonable("abc") == "abc"
def test_jsonable_coerces_decimal_and_date_to_str():
assert _jsonable(Decimal("12.34")) == "12.34"
assert _jsonable(date(2025, 1, 1)) == "2025-01-01"
assert _jsonable(datetime(2025, 1, 1, 12, 0, 0)) == "2025-01-01 12:00:00"
def test_jsonable_truncates_long_strings():
long_str = "x" * 500
out = _jsonable(long_str)
assert isinstance(out, str)
assert len(out) <= 200
def test_jsonable_truncates_long_coerced_values():
class Weird:
def __str__(self):
return "y" * 500
out = _jsonable(Weird())
assert len(out) <= 200
# --- preview_rows on CteTestRecord ---------------------------------------
def test_preview_rows_optional_default_none():
r = _record()
assert r.preview_rows is None
def test_preview_rows_roundtrip_through_ledger(tmp_path):
rec = _record(name="a", preview_rows=[["x", 1], [None, 2.5]])
append_cte_test(tmp_path, rec)
loaded = load_cte_tests(tmp_path)
assert loaded[0].preview_rows == [["x", 1], [None, 2.5]]
def test_old_record_without_preview_rows_still_validates(tmp_path):
# back-compat: an old cte_tests.json row has no "preview_rows" key at all
path = tmp_path / "cte_tests.json"
path.write_text(json.dumps({
"name": "x", "ts": "2025-01-01T00:00:00", "sql_hash": "h", "status": "ok",
}))
loaded = load_cte_tests(tmp_path)
assert len(loaded) == 1
assert loaded[0].preview_rows is None
+110 -2
View File
@@ -29,7 +29,14 @@ def test_cmd(
from rich.table import Table from rich.table import Table
from tht.cli.sql_cmd import do_run from tht.cli.sql_cmd import do_run
from tht.ctetest import CteError, CteTestRecord, append_cte_test, build_test_sql, has_trailing_select from tht.ctetest import (
CteError,
CteTestRecord,
_jsonable,
append_cte_test,
build_test_sql,
has_trailing_select,
)
from tht.execute import ExecutionError from tht.execute import ExecutionError
from tht.execute.warnings import runtime_warnings from tht.execute.warnings import runtime_warnings
@@ -109,6 +116,7 @@ def test_cmd(
name=name, ts=datetime.now(UTC), sql_hash=sql_hash, status="ok", name=name, ts=datetime.now(UTC), sql_hash=sql_hash, status="ok",
columns=result.columns, row_sample=len(result.rows), columns=result.columns, row_sample=len(result.rows),
execution_ms=result.execution_ms, warnings=warnings, execution_ms=result.execution_ms, warnings=warnings,
preview_rows=[[_jsonable(cell) for cell in row] for row in result.rows],
) )
append_cte_test(sdir, record) append_cte_test(sdir, record)
@@ -128,14 +136,25 @@ def test_cmd(
typer.secho(f"OK: esito registrato in {sdir / 'cte_tests.json'}", fg=typer.colors.GREEN) typer.secho(f"OK: esito registrato in {sdir / 'cte_tests.json'}", fg=typer.colors.GREEN)
CTE_PLAN_DOC_FILE = "cte_plan_doc.json"
@cte_app.command("plan") @cte_app.command("plan")
def plan_cmd( def plan_cmd(
session: str = typer.Option(..., "--session"), session: str = typer.Option(..., "--session"),
name: list[str] = typer.Option(..., "--name", help="Nome CTE (ripetibile, in ordine)."), name: list[str] = typer.Option(..., "--name", help="Nome CTE (ripetibile, in ordine)."),
doc: str = typer.Option(
None, "--doc",
help="Path al JSON con la doc della catena (payload A, campo ctes[]), oppure '-' per stdin."),
config: Path = CONFIG_OPT, config: Path = CONFIG_OPT,
) -> None: ) -> None:
"""Persiste il piano CTE ordinato (sessions/<id>/cte_plan.json).""" """Persiste il piano CTE ordinato (sessions/<id>/cte_plan.json).
Con --doc, valida e persiste anche sessions/<id>/cte_plan_doc.json (i nomi in
doc["ctes"] devono combaciare con --name, stesso ordine); cte_plan.json resta
invariato (list[str])."""
import json import json
import sys
from tht.phase import CTE_PLAN_FILE from tht.phase import CTE_PLAN_FILE
@@ -144,8 +163,27 @@ def plan_cmd(
from tht.cli.phase_cmd import require_phase_or_exit from tht.cli.phase_cmd import require_phase_or_exit
require_phase_or_exit(cfg, session, 6) require_phase_or_exit(cfg, session, 6)
sdir = session_dir(cfg, session) sdir = session_dir(cfg, session)
doc_data = None
if doc is not None:
raw = sys.stdin.read() if doc == "-" else Path(doc).read_text()
try:
doc_data = json.loads(raw)
except json.JSONDecodeError as e:
typer.secho(f"ERRORE: --doc JSON non valido: {e}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
doc_names = [c["name"] for c in doc_data["ctes"]]
if doc_names != name:
typer.secho(
f"ERRORE: i nomi in --doc ({doc_names}) non combaciano con --name ({name}).",
fg=typer.colors.RED, err=True,
)
raise typer.Exit(code=1)
sdir.mkdir(parents=True, exist_ok=True) sdir.mkdir(parents=True, exist_ok=True)
(sdir / CTE_PLAN_FILE).write_text(json.dumps(name, ensure_ascii=False)) (sdir / CTE_PLAN_FILE).write_text(json.dumps(name, ensure_ascii=False))
if doc_data is not None:
(sdir / CTE_PLAN_DOC_FILE).write_text(json.dumps(doc_data, ensure_ascii=False))
typer.secho(f"OK: piano CTE salvato ({len(name)} CTE) in {sdir / CTE_PLAN_FILE}.", typer.secho(f"OK: piano CTE salvato ({len(name)} CTE) in {sdir / CTE_PLAN_FILE}.",
fg=typer.colors.GREEN) fg=typer.colors.GREEN)
@@ -168,6 +206,76 @@ def next_cmd(
typer.echo(nxt) typer.echo(nxt)
@cte_app.command("info")
def info_cmd(
name: str = typer.Argument(..., help="Nome del CTE (deve comparire in cte_plan.json)."),
session: str = typer.Option(..., "--session"),
json_out: bool = typer.Option(False, "--json", help="Emetti JSON puro su stdout."),
config: Path = CONFIG_OPT,
) -> None:
"""Dettaglio READ-ONLY di un CTE: posizione nel piano, SQL persistito, doc e ultimo test.
Fonte dati deterministica per il gate JS (WS2) che costruisce il payload cte_result v2."""
import json as _json
from tht.phase import CTE_PLAN_FILE, approved_ctes, cte_plan
cfg = _load_config_or_exit(config)
load_session_or_exit(cfg, session)
sdir = session_dir(cfg, session)
plan = cte_plan(sdir)
if not plan:
typer.secho(f"ERRORE: {CTE_PLAN_FILE} assente o vuoto per la sessione '{session}'.",
fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
if name not in plan:
typer.secho(f"ERRORE: '{name}' non e' nel piano CTE ({plan}).",
fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
cte_file = sdir / "ctes" / f"{name}.sql"
if not cte_file.exists():
typer.secho(f"ERRORE: file CTE non trovato: {cte_file}", fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
doc = None
doc_path = sdir / CTE_PLAN_DOC_FILE
if doc_path.exists():
full_doc = _json.loads(doc_path.read_text())
for c in full_doc.get("ctes", []):
if c.get("name") == name:
doc = {k: v for k, v in c.items() if k != "name"}
break
from tht.ctetest import CteError, load_cte_tests
try:
records = [r for r in load_cte_tests(sdir) if r.name == name]
except CteError as e:
typer.secho(f"ERRORE: impossibile leggere cte_tests.json: {e}",
fg=typer.colors.RED, err=True)
raise typer.Exit(code=1)
last_test = _json.loads(records[-1].model_dump_json()) if records else None
payload = {
"name": name,
"index": plan.index(name) + 1,
"total": len(plan),
"plan": plan,
"sql": cte_file.read_text(),
"approved": name in approved_ctes(sdir),
"doc": doc,
"last_test": last_test,
}
if json_out:
typer.echo(_json.dumps(payload, ensure_ascii=False))
return
typer.echo(f"{name}: {payload['index']}/{payload['total']} — "
f"approvato={payload['approved']}")
@cte_app.command("list") @cte_app.command("list")
def list_cmd( def list_cmd(
session: str = typer.Option(..., "--session"), session: str = typer.Option(..., "--session"),
+15
View File
@@ -25,6 +25,21 @@ class CteTestRecord(BaseModel):
execution_ms: int = 0 execution_ms: int = 0
warnings: list[str] = [] warnings: list[str] = []
error: str | None = None error: str | None = None
preview_rows: list[list[str | int | float | bool | None]] | None = None
_MAX_CELL_CHARS = 200
def _jsonable(v):
"""Coerce a raw DB cell to a JSON-native value for persistence in cte_tests.json.
None/bool/int/float pass through unchanged; str (and anything else, via str())
is truncated to ~200 chars."""
if v is None or isinstance(v, (bool, int, float)):
return v
s = v if isinstance(v, str) else str(v)
return s[:_MAX_CELL_CHARS]
def has_trailing_select(cte_sql: str) -> bool: def has_trailing_select(cte_sql: str) -> bool: