diff --git a/harness/tests/test_preprocess_cli.py b/harness/tests/test_preprocess_cli.py index 38ad21c3..6433bb34 100644 --- a/harness/tests/test_preprocess_cli.py +++ b/harness/tests/test_preprocess_cli.py @@ -2,9 +2,13 @@ import json from pathlib import Path from types import SimpleNamespace +import pytest from typer.testing import CliRunner from tht.cli import app +from tht.ports.evidence import EvidenceSourceError, EvidenceSourceErrorCategory +from tht.ports.vector import VectorStoreError +from tht.vectorstore.embeddings import EmbeddingsError def test_preprocess_evidence_json_is_pristine(monkeypatch, tmp_path): @@ -191,3 +195,64 @@ def test_preprocess_gc_uses_runtime_identity_for_dev_fd_config(monkeypatch, tmp_ command.gc_from_config(Path("/dev/fd/3"), dry_run=True) assert captured["dry_run"] is True assert captured["workspace_id"] == "runtime-workspace" + + +@pytest.mark.parametrize( + "error", + [ + pytest.param( + EvidenceSourceError("secret source", category=EvidenceSourceErrorCategory.PERMANENT), + id="evidence-source", + ), + pytest.param(VectorStoreError("secret vector"), id="vector-store"), + pytest.param(EmbeddingsError("secret embeddings"), id="embeddings"), + ], +) +def test_preprocess_evidence_json_catches_domain_failures_without_stderr(monkeypatch, tmp_path, error): + import tht.cli.preprocess_cmd as command + + monkeypatch.setattr(command, "run_from_config", lambda *a, **k: (_ for _ in ()).throw(error)) + response = CliRunner().invoke( + app, ["preprocess", "evidence", "--json", "-c", str(tmp_path / "workspace.yaml")] + ) + + assert response.exit_code == 1 + assert json.loads(response.stdout) == {"status": "failed", "error": "preprocessing failed"} + assert response.stderr == "" + + +@pytest.mark.parametrize( + "error", + [ + pytest.param( + EvidenceSourceError("secret source", category=EvidenceSourceErrorCategory.PERMANENT), + id="evidence-source", + ), + pytest.param(VectorStoreError("secret vector"), id="vector-store"), + pytest.param(EmbeddingsError("secret embeddings"), id="embeddings"), + ], +) +def test_preprocess_evidence_gc_json_catches_domain_failures_without_stderr(monkeypatch, tmp_path, error): + import tht.cli.preprocess_cmd as command + + monkeypatch.setattr(command, "gc_from_config", lambda *a, **k: (_ for _ in ()).throw(error)) + response = CliRunner().invoke( + app, ["preprocess", "evidence", "gc", "--json", "-c", str(tmp_path / "workspace.yaml")] + ) + + assert response.exit_code == 1 + assert json.loads(response.stdout) == {"status": "failed", "error": "evidence cleanup failed"} + assert response.stderr == "" + + +def test_preprocess_evidence_json_unexpected_failure_has_safe_boundary(monkeypatch, tmp_path): + import tht.cli.preprocess_cmd as command + + monkeypatch.setattr(command, "run_from_config", lambda *a, **k: (_ for _ in ()).throw(Exception("secret unexpected"))) + response = CliRunner().invoke( + app, ["preprocess", "evidence", "--json", "-c", str(tmp_path / "workspace.yaml")] + ) + + assert response.exit_code == 1 + assert json.loads(response.stdout) == {"status": "failed", "error": "preprocessing failed"} + assert response.stderr == "" diff --git a/harness/tests/test_qdrant_cli_commands.py b/harness/tests/test_qdrant_cli_commands.py index d6827edb..0e17bccb 100644 --- a/harness/tests/test_qdrant_cli_commands.py +++ b/harness/tests/test_qdrant_cli_commands.py @@ -225,7 +225,7 @@ def test_vector_index_schema_json_failure_is_safe(monkeypatch, tmp_path): _write_schema_artifacts(tmp_path) monkeypatch.setattr( "tht.adapters.factory.build_vector_store", - lambda cfg, require_write: (_ for _ in ()).throw(RuntimeError("secret qdrant endpoint")), + lambda cfg, require_write: (_ for _ in ()).throw(Exception("secret qdrant endpoint")), ) response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)]) assert response.exit_code != 0 @@ -233,6 +233,7 @@ def test_vector_index_schema_json_failure_is_safe(monkeypatch, tmp_path): payload = json.loads(response.stdout) assert payload == {"status": "failed", "code": "schema_index_failed"} assert "secret qdrant" not in response.stdout + assert response.stderr == "" @@ -294,3 +295,39 @@ def test_vector_index_schema_json_legacy_config_has_no_stderr_on_failure(tmp_pat assert json.loads(response.stdout) == { "status": "failed", "code": "physical_schema_missing" } + + +def test_vector_index_schema_guards_before_artifact_access(tmp_path, monkeypatch): + import tht.cli.vector_cmd as command + + cfg = _legacy_qdrant_runtime_config(tmp_path) + text = cfg.read_text() + text = text.replace("vectors:\n type: qdrant\n base_url: http://qdrant:6333\n collection: psd-clinical\n", "") + text = text.replace("embeddings:\n provider: ollama_internal\n base_url: http://embedding:11434\n model: qwen3-embedding:0.6b\n dim: 1024\n", "") + cfg.write_text(text) + monkeypatch.setattr(command, "_load_schema_artifacts", lambda cfg: (_ for _ in ()).throw(AssertionError("artifact access"))) + response = CliRunner().invoke(app, ["vector", "index-schema", "--json", "-c", str(cfg)]) + + assert response.exit_code == 1 + assert json.loads(response.stdout) == {"status": "failed", "code": "vector_configuration_missing"} + assert response.stderr == "" + + +def test_vector_index_schema_core_reuses_injected_artifacts_without_path_resolution(tmp_path, monkeypatch): + import tht.cli.vector_cmd as command + from tht.config import load_config + from tht.mschema.models import Annotations, PhysicalSchema + + cfg_path = _qdrant_runtime_config(tmp_path) + _write_schema_artifacts(tmp_path) + physical = PhysicalSchema.from_yaml(tmp_path / "artifacts" / "mschema" / "physical.yaml") + annotations = Annotations.from_yaml(tmp_path / "artifacts" / "mschema" / "annotations.yaml") + store = _FakeVectorStore() + monkeypatch.setattr(command, "physical_path", lambda cfg: (_ for _ in ()).throw(AssertionError("physical path"))) + monkeypatch.setattr(command, "annotations_path", lambda cfg: (_ for _ in ()).throw(AssertionError("annotations path"))) + monkeypatch.setattr("tht.adapters.factory.build_vector_store", lambda cfg, require_write: store) + monkeypatch.setattr(command, "make_embedder", lambda _: _FakeEmbedder()) + + payload = command.index_schema_data(load_config(cfg_path), physical=physical, annotations=annotations) + + assert payload["status"] == "succeeded" diff --git a/harness/tests/test_schema_fk_annotations.py b/harness/tests/test_schema_fk_annotations.py index 35747845..c92c5231 100644 --- a/harness/tests/test_schema_fk_annotations.py +++ b/harness/tests/test_schema_fk_annotations.py @@ -228,6 +228,24 @@ def test_suggest_fks_reports_staged_file_without_mined_joins(tmp_path): assert "Minati 0 equi-join da 1 file SQL" in response.output +def test_suggest_fks_human_write_reads_one_annotation_snapshot(tmp_path, monkeypatch): + cfg = _write_workspace(tmp_path) + ann_path = tmp_path / "artifacts" / "mschema" / "annotations.yaml" + Annotations().to_yaml(ann_path) + reads = [] + original = Annotations.from_yaml + + def tracked(path): + reads.append(path) + return original(path) + + monkeypatch.setattr(Annotations, "from_yaml", tracked) + response = CliRunner().invoke(app, ["schema", "suggest-fks", "-c", str(cfg), "--write"]) + + assert response.exit_code == 0, response.output + assert reads == [ann_path] + + def test_suggest_fks_write_merges_and_is_idempotent(tmp_path): cfg = _write_workspace(tmp_path) ann_path = tmp_path / "artifacts" / "mschema" / "annotations.yaml" @@ -601,3 +619,27 @@ def test_fresh_process_human_warning_cardinality_is_one_across_failure_and_write check=True, capture_output=True, text=True, ) assert json.loads(response.stdout)["warnings"] == 0 + + +def test_schema_check_json_unexpected_failure_has_no_stderr_secret(monkeypatch, tmp_path): + import tht.cli.schema_cmd as command + + cfg = _write_workspace(tmp_path) + monkeypatch.setattr(command, "_physical_or_error", lambda cfg: (_ for _ in ()).throw(Exception("secret schema adapter"))) + response = CliRunner().invoke(app, ["schema", "check", "--json", "-c", str(cfg)]) + + assert response.exit_code == 1 + assert json.loads(response.stdout) == {"status": "failed", "code": "schema_check_failed"} + assert response.stderr == "" + + +def test_schema_suggest_json_unexpected_failure_has_no_stderr_secret(monkeypatch, tmp_path): + import tht.cli.schema_cmd as command + + cfg = _write_workspace(tmp_path) + monkeypatch.setattr(command, "_physical_or_error", lambda cfg: (_ for _ in ()).throw(Exception("secret schema adapter"))) + response = CliRunner().invoke(app, ["schema", "suggest-fks", "--json", "-c", str(cfg)]) + + assert response.exit_code == 1 + assert json.loads(response.stdout) == {"status": "failed", "code": "schema_suggestion_failed"} + assert response.stderr == "" diff --git a/harness/tht/cli/preprocess_cmd.py b/harness/tht/cli/preprocess_cmd.py index dd15c40a..996dfa2f 100644 --- a/harness/tht/cli/preprocess_cmd.py +++ b/harness/tht/cli/preprocess_cmd.py @@ -4,6 +4,7 @@ from __future__ import annotations import hashlib import json +import logging import re from pathlib import Path @@ -12,8 +13,17 @@ import typer from tht.cli.config_cmd import CONFIG_OPT from tht.cli.schema_cmd import _load_config_or_exit from tht.config import workspace_id_for_config +from tht.ports.evidence import EvidenceSourceError +from tht.ports.vector import VectorStoreError +from tht.vectorstore.embeddings import EmbeddingsError preprocess_app = typer.Typer(help="Materialize versioned preprocessing artifacts") +logger = logging.getLogger(__name__) + +_PREPROCESS_EXPECTED_ERRORS = ( + OSError, RuntimeError, ValueError, TypeError, KeyError, + EvidenceSourceError, VectorStoreError, EmbeddingsError, +) def run_dwh_from_config( @@ -132,7 +142,16 @@ def evidence_cmd( if action == "gc": try: payload = gc_from_config(config, dry_run=dry_run) - except (OSError, RuntimeError, ValueError, TypeError, KeyError): + except _PREPROCESS_EXPECTED_ERRORS: + payload = {"status": "failed", "error": "evidence cleanup failed"} + if json_output: + typer.echo(json.dumps(payload, sort_keys=True)) + else: + typer.secho("ERRORE: evidence cleanup failed", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) from None + except Exception: + if not json_output: + logger.exception("Evidence cleanup failed") payload = {"status": "failed", "error": "evidence cleanup failed"} if json_output: typer.echo(json.dumps(payload, sort_keys=True)) @@ -153,7 +172,16 @@ def evidence_cmd( raise typer.Exit(code=2) try: result = run_from_config(config, dry_run=dry_run, resume=resume) - except (OSError, RuntimeError, ValueError, TypeError, KeyError): + except _PREPROCESS_EXPECTED_ERRORS: + payload = {"status": "failed", "error": "preprocessing failed"} + if json_output: + typer.echo(json.dumps(payload, sort_keys=True)) + else: + typer.secho("ERRORE: preprocessing failed", fg=typer.colors.RED, err=True) + raise typer.Exit(code=1) from None + except Exception: + if not json_output: + logger.exception("Evidence preprocessing failed") payload = {"status": "failed", "error": "preprocessing failed"} if json_output: typer.echo(json.dumps(payload, sort_keys=True)) diff --git a/harness/tht/cli/schema_cmd.py b/harness/tht/cli/schema_cmd.py index 879f1110..6a8bcdad 100644 --- a/harness/tht/cli/schema_cmd.py +++ b/harness/tht/cli/schema_cmd.py @@ -448,10 +448,10 @@ def check_cmd( raise typer.Exit(code=1) from None _render_schema_machine_error(error, cfg) except Exception: - logger.exception("Schema check failed") if json_output: _schema_json({"status": "failed", "code": "schema_check_failed"}) raise typer.Exit(code=1) from None + logger.exception("Schema check failed") typer.secho("ERRORE: impossibile verificare le annotazioni.", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) from None if json_output: @@ -531,10 +531,10 @@ def suggest_fks_cmd( raise typer.Exit(code=1) from None _render_schema_machine_error(error, cfg) except Exception: - logger.exception("Schema suggestion failed") if json_output: _schema_json({"status": "failed", "code": "schema_suggestion_failed"}) raise typer.Exit(code=1) from None + logger.exception("Schema suggestion failed") typer.secho("ERRORE: impossibile elaborare il catalogo.", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) from None if json_output: diff --git a/harness/tht/cli/vector_cmd.py b/harness/tht/cli/vector_cmd.py index b0acb55f..4e02b974 100644 --- a/harness/tht/cli/vector_cmd.py +++ b/harness/tht/cli/vector_cmd.py @@ -208,9 +208,10 @@ def index_schema_data( cfg = config _vector_write_or_error(cfg) _vector_cfg_or_error(cfg) - phys_file = physical_path(cfg) - if not phys_file.exists(): - raise _MachineVectorError("physical_schema_missing") + if physical is None: + phys_file = physical_path(cfg) + if not phys_file.exists(): + raise _MachineVectorError("physical_schema_missing") import yaml from pydantic import ValidationError @@ -259,6 +260,9 @@ def index_schema_cmd( else: cfg = _load_config_or_exit(config) try: + # Authorization and configuration are checked before touching any artifacts. + _vector_write_or_error(cfg) + _vector_cfg_or_error(cfg) physical, annotations = _load_schema_artifacts(cfg) payload = index_schema_data(cfg, physical=physical, annotations=annotations) except _MachineVectorError as error: @@ -267,10 +271,10 @@ def index_schema_cmd( raise typer.Exit(code=1) from None _render_index_schema_error(error, cfg) except Exception: - logger.exception("Schema indexing failed") if json_output: typer.echo(json.dumps({"status": "failed", "code": "schema_index_failed"}, sort_keys=True, separators=(",", ":"))) raise typer.Exit(code=1) from None + logger.exception("Schema indexing failed") typer.secho("ERRORE: impossibile indicizzare lo schema.", fg=typer.colors.RED, err=True) raise typer.Exit(code=1) from None if json_output: