refactor(cli): remove obsolete workflow commands
This commit is contained in:
@@ -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(
|
||||
|
||||
@@ -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
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user