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
+110 -2
View File
@@ -29,7 +29,14 @@ def test_cmd(
from rich.table import Table
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.warnings import runtime_warnings
@@ -109,6 +116,7 @@ def test_cmd(
name=name, ts=datetime.now(UTC), sql_hash=sql_hash, status="ok",
columns=result.columns, row_sample=len(result.rows),
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)
@@ -128,14 +136,25 @@ def test_cmd(
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")
def plan_cmd(
session: str = typer.Option(..., "--session"),
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,
) -> 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 sys
from tht.phase import CTE_PLAN_FILE
@@ -144,8 +163,27 @@ def plan_cmd(
from tht.cli.phase_cmd import require_phase_or_exit
require_phase_or_exit(cfg, session, 6)
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 / 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}.",
fg=typer.colors.GREEN)
@@ -168,6 +206,76 @@ def next_cmd(
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")
def list_cmd(
session: str = typer.Option(..., "--session"),