fix(harness): remediation difetti review — gate↔CLI, D15, D7/D6, D14, robustezza

Implementazione del piano di remediation progressiva sui difetti emersi
dall'analisi dell'harness. Tutto verificato: 214 test Python (incl. L0 su
Postgres reale), 14 test JS del gate, ruff pulito.

Blocco 1 (CRITICA, integrazione gate↔CLI):
- phase advance: gate usa --auto + exit 6; reviewer_confirm kind:phase fa
  advance esplicito che applica i prerequisiti (prima non avanzava per le
  fasi a conferma umana).
- cte plan riceve i --name dal gate (param names); set-question con id
  posizionale; skill `tht search find`; nuovo comando `tht memory save-one`
  con dedup hash client-side in save_one_memory.

Blocco 2 (D15, stato post-rollback):
- campo `phase` su DecisionRecord + effective_decisions phase-aware per i
  subject "a nome" (cte_approved ecc.); _compute_promotions e finalize sulla
  vista effective; finalize confronta col piano CTE effettivo, non glob;
  `decision add --retracts` + comando `decision retract`.

Blocco 3 (D7 read-only + D6 manifest):
- assert_read_only su tutti e quattro i codepath (direct + REST);
- manifest author/summary/updated_at/updated_by/schema_version popolati +
  helper touch_manifest sulle mutazioni.

Blocco 4-5 (D14a/D14b):
- decision_min_phase data-driven via `emits:` in workflow.yaml;
- formula evidence: status auto, search_formulas, gruppo CLI `tht formula`,
  `search find --kind formula`, load_evidence_dir salta i .sql.md.

Blocco 6 (robustezza):
- taskdoc slice promoted_tables + bound enforced; report escaping/bound +
  rsplit note; filtro kind reader REST/direct; conteggio upserted robusto;
  guard REST run_query non-list; LSH disallineato -> LshIndexError.

Blocco 7 (pulizia):
- dead code gate e KIND_TO_TABLE morto rimossi; doc Postgres-only
  (README + connection.py).

Blocco 0 (parziale): test di compatibilità firma gate↔CLI
(tests/integration). Rinviati: fake-Pi runtime completo, artifact-gate da
disco (#23), parità eligibility REST/direct (#28), unificazione
reserved-labels (#30), memory_rejected da deselezione (#33).

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-27 17:16:51 +02:00
co-authored by Claude Opus 4.8
parent daf33f77fe
commit c4d130828f
36 changed files with 912 additions and 83 deletions
@@ -0,0 +1,74 @@
"""Integration: every `tht ...` command the gate invokes must exist in the Typer CLI.
Root cause of the Blocco 1 critical bugs: the gate (tht-gate.js) and the Python CLI
were ported separately and never run together, so the gate called commands/flags that
did not exist (`phase advance --if-ready`, `cte plan` without `--name`, `set-question
--session` on a positional arg). This test extracts every `["group","sub",...,"--flag"]`
array literal from tht-gate.js and asserts, via `tht <group> <sub> --help`, that the
subcommand exists (exit 0) and that each long flag used is offered. It makes that whole
class of drift impossible to reintroduce silently.
"""
from __future__ import annotations
import re
import subprocess
import sys
from pathlib import Path
import pytest
_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"}
# Flags that are framework/JS artifacts, never real CLI options (skip from the check).
_SKIP_FLAGS: set[str] = set()
def _extract_invocations() -> list[tuple[str, str, list[str], str]]:
"""Returns (group, subcommand, long_flags, raw) for each gate CLI call site."""
src = _GATE.read_text()
# array literal opening with two string literals: ["group", "sub" ...
pattern = re.compile(r'\[\s*"([a-z-]+)"\s*,\s*"([a-z][a-z-]*)"((?:\s*,\s*[^\]\[]+?)?)\]')
out: list[tuple[str, str, list[str], str]] = []
for m in pattern.finditer(src):
group, sub, tail = m.group(1), m.group(2), m.group(3)
if group not in _GROUPS:
continue
flags = [f for f in re.findall(r'"(--[a-z][a-z-]*)"', tail) if f not in _SKIP_FLAGS]
out.append((group, sub, flags, m.group(0)))
return out
_INVOCATIONS = _extract_invocations()
def test_gate_invokes_at_least_the_known_commands():
"""Guards the extractor itself: if it silently matches nothing, the test is useless."""
pairs = {(g, s) for g, s, _, _ in _INVOCATIONS}
assert ("phase", "advance") in pairs
assert ("cte", "plan") in pairs
assert ("session", "set-question") in pairs
assert ("decision", "add") in pairs
@pytest.mark.skipif(not _THT.exists(), reason="tht CLI not installed in this venv")
@pytest.mark.parametrize("group,sub,flags,raw", _INVOCATIONS, ids=lambda v: v if isinstance(v, str) else None)
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),
)
assert res.returncode == 0, (
f"gate calls `tht {group} {sub}` but it does not exist in the CLI.\n"
f"call site: {raw}\nstderr: {res.stderr}"
)
help_text = res.stdout + res.stderr
for flag in flags:
assert flag in help_text, (
f"gate passes `{flag}` to `tht {group} {sub}` but the CLI does not offer it.\n"
f"call site: {raw}"
)
+54
View File
@@ -0,0 +1,54 @@
"""Blocco 6: robustness fixes -- taskdoc slice/bound, report escaping, upsert count."""
from tht.report import _markdown_table, extract_reviewer_notes
from tht.taskdoc import generate_task_doc
def test_taskdoc_slices_to_promoted_tables(tmp_path):
s = tmp_path / "sess"
s.mkdir()
(s / "question.md").write_text("q")
(s / "schema_linking.json").write_text(
'{"question":"q","candidates":['
'{"kind":"table","name":"pazienti","decision":"promoted"},'
'{"kind":"table","name":"ricoveri","decision":"promoted"}],'
'"joins":[],"excluded":[],"open_questions":[]}'
)
doc = generate_task_doc(session_dir=s, phase=4, promoted_tables=["pazienti"])
assert "pazienti" in doc.body
assert "ricoveri" not in doc.body # sliced out
def test_taskdoc_truncates_over_budget(tmp_path):
s = tmp_path / "sess"
s.mkdir()
(s / "question.md").write_text("# Domanda\n" + "x" * 200_000)
doc = generate_task_doc(session_dir=s, phase=1)
assert doc.byte_budget_ok is False
assert len(doc.body.encode()) <= 80_000
assert "troncato" in doc.body
def test_markdown_table_escapes_pipes_and_newlines():
table = _markdown_table(["c"], [("a|b\nc",)])
# the cell must not introduce a raw pipe or newline that breaks the row
body_line = table.splitlines()[2]
assert "\\|" in body_line
assert "\n" not in body_line
def test_extract_reviewer_notes_uses_last_heading():
report = (
"## Note del reviewer\nnella cella di dati appariva questo testo\n"
"## Note del reviewer\nnota vera del reviewer"
)
assert extract_reviewer_notes(report) == "nota vera del reviewer"
def test_upsert_count_handles_postgrest_list_wrapping():
from unittest.mock import MagicMock
from tht.vectorstore.rest_client import VectorRestClient
client = VectorRestClient.__new__(VectorRestClient)
client._call = MagicMock(return_value=[{"upserted": 7}]) # list-wrapped scalar
assert client.upsert_records("memory", [{}, {}]) == 7
+37
View File
@@ -0,0 +1,37 @@
"""decision_min_phase must cover every substantive decision type (data-driven via emits).
The reference bug: only the ~5 types referenced in prerequisites had a min phase; all
others (table_promoted, cte_approved, value_grounded, concept_formula_*, ...) defaulted
to phase 1, so require_phase_or_exit would accept them far too early. workflow.yaml now
declares `emits` per phase as the source of truth.
"""
import pytest
from tht.workflow import load_workflow
@pytest.mark.parametrize(
"dtype,expected",
[
("concept_clarified", 1),
("memory_rejected", 2),
("question_rewritten", 3),
("table_promoted", 4),
("column_corrected", 4),
("evidence_accepted", 4),
("value_grounded", 4),
("concept_formula_approved", 4),
("concept_formula_rejected", 4),
("cte_approved", 6),
("sql_approved", 7),
("sql_revised", 7),
("datamart_requested", 8),
],
)
def test_decision_min_phase_from_emits(dtype, expected):
assert load_workflow().decision_min_phase(dtype) == expected
@pytest.mark.parametrize("meta", ["phase_approved", "phase_reopened", "decision_retracted"])
def test_meta_types_not_phase_gated(meta):
assert load_workflow().decision_min_phase(meta) == 1
@@ -0,0 +1,46 @@
"""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
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)
monkeypatch.setattr(mod, "session_dir", lambda _cfg, sid: tmp_path / 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
@@ -0,0 +1,47 @@
"""D15 (§4.8): effective_decisions must stale-filter name-subject decisions too.
The reference bug (CRITICA #1): effective_decisions only filtered decisions whose
subject was "phase:N". cte_approved uses the CTE *name* as subject, so a cte_approved
from F6 stayed "effective" after a rollback to F4 -- a stale decision that still counted
(the exact invariant §4.8 forbids). The fix records the emitting phase on each
DecisionRecord (high-water-mark) and effective_decisions falls back to it when the
subject is not "phase:N".
"""
from pathlib import Path
from tht.decisions import append_decision
from tht.phase import approved_ctes, current_phase, effective_decisions
def _walk_to_phase(session: Path, target: int) -> None:
"""Approva in ordine fino a raggiungere `target` (il fold avanza solo su n==cur)."""
while current_phase(session) < target:
append_decision(session, type="phase_approved", subject=f"phase:{current_phase(session)}")
def test_cte_approved_name_subject_is_stale_after_reopen(tmp_path):
s = tmp_path / "sess"
s.mkdir()
_walk_to_phase(s, 6) # ora in F6
# piano CTE + approvazione di un CTE (subject = NOME del CTE, non "phase:6")
(s / "cte_plan.json").write_text('["pazienti_base"]')
append_decision(s, type="cte_approved", subject="pazienti_base")
assert "pazienti_base" in approved_ctes(s)
assert current_phase(s) == 6
# rollback a F4: la cte_approved di F6 deve diventare stale (esclusa dalla vista)
append_decision(s, type="phase_reopened", subject="phase:4")
assert current_phase(s) == 4
assert "pazienti_base" not in approved_ctes(s), (
"cte_approved (subject a nome) di F6 NON deve contare dopo un reopen a F4"
)
types = [d.type for d in effective_decisions(s)]
assert "cte_approved" not in types
def test_phase_field_recorded_on_append(tmp_path):
s = tmp_path / "sess"
s.mkdir()
_walk_to_phase(s, 4)
rec = append_decision(s, type="value_grounded", subject="ablazione")
assert rec.phase == 4 # emitting phase recorded for the high-water-mark filter
+37
View File
@@ -0,0 +1,37 @@
"""D14b wiring: status=auto, search_formulas, and evidence loader skips formula files.
Completes the formula layer beyond the store: the `auto` status the spec requires
(§4.7.2), the concept-substring retrieval that `tht search find --kind formula` uses,
and the guarantee that load_evidence_dir does NOT choke on *.sql.md formula files when
they live under the evidence root.
"""
from tht.evidence.formula_store import ConceptFormula, save_formula, search_formulas
from tht.evidence.model import EvidenceDoc, load_evidence_dir
def test_status_auto_is_valid():
f = ConceptFormula(concept="x", sql="SELECT 1", status="auto")
assert f.status == "auto"
# round-trips through parse/dump
assert ConceptFormula.parse(f.dump()).status == "auto"
def test_search_formulas_substring_case_insensitive(tmp_path):
save_formula(tmp_path, ConceptFormula(concept="fascia pediatrica", sql="SELECT 1"))
save_formula(tmp_path, ConceptFormula(concept="indice di Charlson", sql="SELECT 2"))
hits = search_formulas(tmp_path, "PEDIATRICA")
assert len(hits) == 1
assert hits[0].concept == "fascia pediatrica"
assert search_formulas(tmp_path, "charlson")[0].concept == "indice di Charlson"
def test_load_evidence_dir_skips_formula_files(tmp_path):
# a real evidence doc + a formula file under the same root
(tmp_path / "ev1.md").write_text(
"---\nid: ev1\ntitle: T\n---\nbody text\n"
)
save_formula(tmp_path, ConceptFormula(concept="ablazione", sql="SELECT 1"))
docs = load_evidence_dir(tmp_path)
ids = [d.id for d in docs]
assert ids == ["ev1"] # the .sql.md formula file is skipped, no crash
assert all(isinstance(d, EvidenceDoc) for d in docs)
+43
View File
@@ -0,0 +1,43 @@
"""D6 §5.2: the manifest's new fields are actually written (not just declared).
The reference bug: author/summary/updated_at/updated_by/schema_version existed on the
model but no code ever populated them. These tests pin that create_session fills them
and that mutations (set_question, touch_manifest) bump updated_at/updated_by.
"""
from tht.config import DatabaseConfig
from tht.session.store import create_session, load_session, set_question, touch_manifest
def _db() -> DatabaseConfig:
return DatabaseConfig(host="h", port=5432, database="dw", user="u", password="p", schema="dwh")
def test_create_session_populates_new_fields(tmp_path, monkeypatch):
monkeypatch.setenv("THT_AUTHOR", "alice@psd")
m = create_session("Quanti ricoveri per ablazione nel 2024?", _db(), tmp_path)
assert m.author == "alice@psd"
assert m.updated_by == "alice@psd"
assert m.summary and m.summary.startswith("Quanti ricoveri")
assert m.created_at is not None
assert m.updated_at == m.created_at
assert m.schema_version is not None # from workflow.yaml
def test_default_author_when_env_absent(tmp_path, monkeypatch):
monkeypatch.delenv("THT_AUTHOR", raising=False)
m = create_session("x", _db(), tmp_path)
assert m.author == "dev@local"
def test_touch_manifest_bumps_updated(tmp_path, monkeypatch):
monkeypatch.setenv("THT_AUTHOR", "alice@psd")
m = create_session("domanda", _db(), tmp_path)
created = m.updated_at
monkeypatch.setenv("THT_AUTHOR", "bob@psd")
set_question(m.id, "domanda riscritta", [], tmp_path)
reloaded = load_session(m.id, tmp_path)
assert reloaded.updated_by == "bob@psd"
assert reloaded.updated_at >= created
# touch_manifest helper is reachable and updates the field
touch_manifest(m.id, tmp_path, updated_by="carol@psd")
assert load_session(m.id, tmp_path).updated_by == "carol@psd"
+37
View File
@@ -0,0 +1,37 @@
"""D7: read-only enforcement lives in the execute layer, not only in CLI callers.
assert_read_only is the shared structural guard both codepaths (direct + REST) call,
so reusing the execute API (e.g. from the backend) cannot bypass single-statement +
SELECT-only. Pins that writes/multi-statement are rejected with ExecutionError.
"""
import pytest
from tht.execute import ExecutionError, assert_read_only
@pytest.mark.parametrize(
"sql",
[
"DELETE FROM pazienti",
"UPDATE pazienti SET x = 1",
"INSERT INTO pazienti VALUES (1)",
"DROP TABLE pazienti",
"SELECT 1; DROP TABLE pazienti", # multi-statement
"TRUNCATE pazienti",
],
)
def test_write_or_multistatement_rejected(sql):
with pytest.raises(ExecutionError):
assert_read_only(sql)
@pytest.mark.parametrize(
"sql",
[
"SELECT 1",
"WITH x AS (SELECT 1) SELECT * FROM x",
"SELECT a FROM t UNION SELECT b FROM u",
],
)
def test_select_allowed(sql):
assert_read_only(sql) # no raise