refactor(cli): remove obsolete workflow commands

This commit is contained in:
2026-08-16 02:29:00 +02:00
parent 91650d9c67
commit 7651b63cea
19 changed files with 211 additions and 646 deletions
+85
View File
@@ -0,0 +1,85 @@
{
"maintained": [
"cte info",
"cte next",
"cte plan",
"cte save",
"cte test",
"datamart generate",
"db ping",
"decision add",
"decision add-batch",
"decision add-join-set",
"memory promote",
"memory save-one",
"memory search",
"memory solved-index",
"memory solved-search",
"ollama ensure",
"phase advance",
"phase meta",
"phase reopen",
"phase show",
"preprocess dwh",
"preprocess evidence",
"schema check",
"schema columns",
"schema introspect",
"schema render",
"schema suggest-fks",
"search find",
"search pack",
"session archive",
"session check",
"session close",
"session delete",
"session documents",
"session fail",
"session finalize",
"session list",
"session migrate",
"session new",
"session preferences get",
"session preferences set",
"session reopen",
"session retrieval-pack",
"session set-group",
"session set-name",
"session set-question",
"session set-schema-linking",
"session show",
"session sync-schema-linking",
"session unarchive",
"sql export",
"sql preview",
"sql set-final",
"sql validate",
"vector index-schema"
],
"enhanced": [
"config check",
"db fetch-ca",
"doctor",
"memory delete",
"memory index",
"memory list",
"memory show",
"memory update"
],
"erased": [
"cte list",
"decision list",
"decision retract",
"evidence extract",
"evidence index",
"formula list",
"formula save",
"lsh build",
"lsh query",
"memory clear",
"memory migrate",
"sql explain",
"sql save",
"vector init"
]
}
@@ -16,13 +16,48 @@ import sys
from pathlib import Path
import pytest
from typer.main import get_command
from tht.cli import app
_ROOT = Path(__file__).resolve().parent.parent.parent
_GATE = _ROOT / ".pi" / "extensions" / "tht-gate.js"
_THT = Path(sys.executable).parent / "tht"
# The command groups the gate drives. Anything else in an array literal is data, not a CLI call.
_GROUPS = {"phase", "session", "cte", "decision", "memory", "search", "schema", "vector"}
_GROUPS = {"phase", "session", "cte", "decision", "memory", "search", "schema", "sql", "vector"}
# Python's workflow CLI is also a contract for its non-gate consumers. Keep this
# list literal rather than deriving it from the current tree: a deleted live
# command must fail this test before a container or preprocessing job reaches it.
_LIVE_CONSUMER_COMMANDS = {
"Pi skill": {
("cte", "info"), ("cte", "save"), ("cte", "test"),
("datamart", "generate"), ("memory", "promote"),
("memory", "save-one"), ("memory", "search"),
("memory", "solved-index"), ("memory", "solved-search"),
("phase", "advance"), ("phase", "reopen"),
("schema", "introspect"), ("schema", "render"),
("search", "find"), ("search", "pack"),
("session", "check"), ("session", "documents"),
("session", "finalize"), ("session", "show"),
("sql", "preview"), ("sql", "set-final"), ("sql", "validate"),
},
"backend ThtRunner": {
("db", "ping"), ("ollama", "ensure"), ("search", "pack"),
("session", "archive"), ("session", "close"), ("session", "delete"),
("session", "documents"), ("session", "fail"), ("session", "list"),
("session", "new"), ("session", "reopen"), ("session", "set-group"),
("session", "set-name"), ("session", "show"), ("session", "unarchive"),
("session", "preferences", "get"), ("session", "preferences", "set"),
("sql", "export"), ("sql", "preview"),
},
"workspace preprocessing": {
("preprocess", "dwh"), ("preprocess", "evidence"),
("schema", "check"), ("schema", "suggest-fks"), ("vector", "index-schema"),
},
"deployment smoke": {("config", "check"), ("db", "ping")},
}
# Flags that are framework/JS artifacts, never real CLI options (skip from the check).
_SKIP_FLAGS: set[str] = set()
@@ -53,6 +88,30 @@ def test_gate_invokes_at_least_the_known_commands():
assert ("cte", "plan") in pairs
assert ("session", "set-question") in pairs
assert ("decision", "add") in pairs
assert ("sql", "set-final") in pairs
def _leaf_paths(command, prefix: tuple[str, ...] = ()) -> set[tuple[str, ...]]:
children = getattr(command, "commands", {})
if not children:
return {prefix} if prefix else set()
paths: set[tuple[str, ...]] = set()
for name, child in children.items():
paths.update(_leaf_paths(child, prefix + (name,)))
return paths
@pytest.mark.parametrize(
("consumer", "path"),
[
(consumer, path)
for consumer, paths in _LIVE_CONSUMER_COMMANDS.items()
for path in sorted(paths)
],
)
def test_live_workflow_consumers_keep_their_command_paths(consumer: str, path: tuple[str, ...]):
"""Live Pi, backend, preprocessing, and smoke paths must remain callable."""
assert path in _leaf_paths(get_command(app)), f"{consumer} requires `tht {' '.join(path)}`"
@pytest.mark.skipif(not _THT.exists(), reason="tht CLI not installed in this venv")
@@ -60,7 +119,7 @@ def test_gate_invokes_at_least_the_known_commands():
def test_gate_call_site_matches_cli(group: str, sub: str, flags: list[str], raw: str):
res = subprocess.run(
[str(_THT), group, sub, "--help"],
capture_output=True, text=True, cwd=str(_ROOT),
capture_output=True, check=False, text=True, cwd=str(_ROOT),
)
assert res.returncode == 0, (
f"gate calls `tht {group} {sub}` but it does not exist in the CLI.\n"
@@ -22,16 +22,16 @@ WORKSPACE = Path(os.environ.get("THT_L2_WORKSPACE", "/Users/mp/projects/tht-work
def test_ablazione_returns_multiple_columns(l2_env):
"""On the real schema, 'ablazione' should ground to more than one column (e.g.
a flag and a free-text patologia field) -- the whole point of D14a's
non-collapsing aggregation. Requires a built LSH index (tht lsh build)."""
non-collapsing aggregation. Requires a built LSH index (preprocess dwh --steps lsh)."""
if not WORKSPACE.exists():
pytest.skip(f"per-customer workspace repo not found: {WORKSPACE} (clone tht-workspace-psd)")
try:
from tht.lshindex import load_index, query_index # ported with the lsh build path
except ModuleNotFoundError:
pytest.skip("tht.lshindex not yet ported (deferred from B3; lands with tht lsh build)")
pytest.skip("tht.lshindex not yet ported (deferred from B3 preprocessing)")
from tht.search import aggregate_lsh_multi
# The index lives at <indexes>/lsh/<schema>_*.pkl (same convention as tht lsh build/query),
# The index lives at <indexes>/lsh/<schema>_*.pkl (the preprocess-dwh convention),
# and is keyed by the database schema name (cfg.database.db_schema), not a hard-coded literal.
ws = load_workspace(WORKSPACE)
index_dir = ws.paths.indexes / "lsh"
@@ -39,7 +39,7 @@ def test_ablazione_returns_multiple_columns(l2_env):
try:
lsh, minhashes, meta = load_index(index_dir, schema_name)
except Exception as e:
pytest.skip(f"LSH index not built yet (run tht lsh build -c {WORKSPACE}): {e}")
pytest.skip(f"LSH index not built yet (run tht preprocess dwh --steps lsh -c {WORKSPACE}): {e}")
hits = query_index(lsh, minhashes, "ablazione", meta, top_n=20)
grouped = aggregate_lsh_multi(
+48
View File
@@ -0,0 +1,48 @@
"""The public Python CLI exposes exactly the approved workflow surface."""
from __future__ import annotations
import json
from pathlib import Path
from typer.main import get_command
from typer.testing import CliRunner
from tht.cli import app
_FIXTURE = Path(__file__).parent / "fixtures" / "approved_cli_surface.json"
def _leaf_paths(command, prefix: tuple[str, ...] = ()) -> set[str]:
"""Return the terminal paths offered by the real Typer/Click command tree."""
children = getattr(command, "commands", {})
if not children:
return {" ".join(prefix)} if prefix else set()
paths: set[str] = set()
for name, child in children.items():
paths.update(_leaf_paths(child, prefix + (name,)))
return paths
def _approved_surface() -> dict[str, list[str]]:
return json.loads(_FIXTURE.read_text(encoding="utf-8"))
def test_typer_tree_matches_the_approved_command_surface():
"""Catches a missing live command or an accidentally re-registered erased command."""
approved = _approved_surface()
expected = set(approved["maintained"]) | set(approved["enhanced"])
assert len(approved["maintained"]) == 55
assert len(approved["enhanced"]) == 8
assert len(approved["erased"]) == 14
assert not (expected & set(approved["erased"]))
assert _leaf_paths(get_command(app)) == expected
def test_erased_commands_are_rejected_with_the_standard_typer_error():
"""Catches a regression that restores a retired command under an existing group."""
runner = CliRunner()
for path in _approved_surface()["erased"]:
result = runner.invoke(app, [*path.split(), "--help"])
assert result.exit_code != 0, path
assert "no such command" in result.output.casefold(), path
@@ -1,63 +0,0 @@
"""D15 granularity (a): `tht decision retract` tombstones the last substantive decision.
Wires the step-granularity rollback ("re-ask current widget, discard last answer") to a
reachable CLI command. The data model (decision_retracted + retracts, honored by
effective_decisions) already existed; this pins the command that emits it.
"""
from pathlib import Path
from tht.cli.decision_cmd import retract_cmd
from tht.decisions import append_decision, list_decisions
from tht.phase import current_phase, effective_decisions
def _walk_to_phase(session: Path, target: int) -> None:
while current_phase(session) < target:
append_decision(session, type="phase_approved", subject=f"phase:{current_phase(session)}")
def test_retract_drops_last_substantive_decision(tmp_path, monkeypatch):
s = tmp_path / "2026-01-01-000000-x"
s.mkdir(parents=True)
_walk_to_phase(s, 4)
append_decision(s, type="table_promoted", subject="phase:4", detail="dim_pazienti")
append_decision(s, type="table_promoted", subject="phase:4", detail="fact_ricoveri")
# stub config + session loading (the command only needs a session dir)
import tht.cli.decision_cmd as mod
import tht.cli.session_cmd as session_mod
from tht.session.models import SessionManifest, SessionSnapshot
class _Cfg:
class paths:
sessions = tmp_path
monkeypatch.setattr(mod, "_load_config_or_exit", lambda _c: _Cfg())
monkeypatch.setattr(mod, "load_session_or_exit", lambda _cfg, _s: None)
class _Repository:
def get(self, session_id):
return SessionSnapshot(
manifest=SessionManifest(id=session_id, created_at="2026-01-01T00:00:00Z", question="q", database="d", schema="s"),
decisions=list_decisions(tmp_path / session_id),
)
def append_decisions(self, session_id, decisions):
from tht.decisions import append_decisions
return append_decisions(tmp_path / session_id, list(decisions))
repository = _Repository()
monkeypatch.setattr(mod, "load_snapshot_or_exit", lambda _cfg, sid: repository.get(sid))
monkeypatch.setattr(mod, "session_repository", lambda _cfg: repository)
monkeypatch.setattr(session_mod, "load_snapshot_or_exit", lambda _cfg, sid: repository.get(sid))
retract_cmd(session="2026-01-01-000000-x", config=Path("x"))
eff = effective_decisions(s)
promoted = [d for d in eff if d.type == "table_promoted"]
assert len(promoted) == 1 # the last one was retracted
assert promoted[0].detail == "dim_pazienti"
# the audit log keeps everything (append-only): 2 promotions + the retract marker
raw = [d.type for d in list_decisions(s)]
assert raw.count("table_promoted") == 2
assert "decision_retracted" in raw
+2 -24
View File
@@ -21,7 +21,6 @@ class _FakeEmbedder:
class _FakeVectorStore:
def __init__(self):
self.upserts = []
self.deleted = []
def existing_hashes(self, collection, kinds):
return {}
@@ -30,11 +29,6 @@ class _FakeVectorStore:
self.upserts.append((collection, records))
return len(records)
def delete_kinds(self, collection, kinds):
self.deleted.append((collection, list(kinds)))
return 3
def _sha_file(path: Path) -> str:
return "sha256:" + hashlib.sha256(path.read_bytes()).hexdigest()
@@ -220,28 +214,12 @@ def test_memory_index_accepts_qdrant_only_runtime_config(tmp_path, monkeypatch):
assert store.upserts
def test_memory_clear_accepts_qdrant_only_runtime_config(tmp_path, monkeypatch):
cfg = _qdrant_runtime_config(tmp_path)
store = _FakeVectorStore()
records = [_memory_record()]
registry = tmp_path / "artifacts" / "memory" / "registry.jsonl"
save_registry(records, registry)
monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda cfg, require_write: store)
res = CliRunner().invoke(app, ["memory", "clear", "--yes", "-c", str(cfg)])
assert res.exit_code == 0, res.output
assert store.deleted == [("memory", ["memory"])]
assert not registry.exists()
def test_vector_help_does_not_expose_migrate_and_keeps_qdrant_commands():
def test_vector_help_exposes_only_the_supported_qdrant_command():
res = CliRunner().invoke(app, ["vector", "--help"])
assert res.exit_code == 0, res.output
assert "migrate" not in res.output
assert "init" in res.output
assert "init" not in res.output
assert "index-schema" in res.output
@@ -63,35 +63,6 @@ def test_introspect_fresh_root_initializes_through_writer_job(tmp_path, monkeypa
assert "1 tabelle" in res.output
def test_lsh_build_fresh_root_initializes_introspection_and_lsh(tmp_path, monkeypatch):
import tht.cli.lsh_cmd as lsh_module
import tht.cli.schema_cmd as schema_module
import tht.lshindex as lshindex_module
cfg = _write_config(tmp_path)
physical = PhysicalSchema(
database="d", schema="s", introspected_at=datetime(2026, 1, 1),
tables={"dim_patient": TablePhysical(columns={"id": ColumnPhysical(type="bigint")})},
)
def refresh(_cfg, *, output_path=None, **_kwargs):
physical.to_yaml(output_path)
return physical
def build(_cfg, *, physical_file, output_dir, **_kwargs):
assert physical_file.is_file()
for name in ("s_lsh.pkl", "s_minhashes.pkl", "s_meta.json"):
(output_dir / name).write_text("index")
return {}, [], [], {}
monkeypatch.setattr(schema_module, "refresh_catalog", refresh)
monkeypatch.setattr(lsh_module, "build_lsh_artifacts", build)
monkeypatch.setattr(lshindex_module, "load_index", lambda *_args, **_kwargs: (None, {}, None))
res = CliRunner().invoke(app, ["lsh", "build", "-c", str(cfg)])
assert res.exit_code == 0, res.output
assert (tmp_path / ".tht-dwh" / "OWNER.json").is_file()
def test_introspect_refresh_bypasses_cache(tmp_path):
_write_catalog(tmp_path)
cfg = _write_config(tmp_path)