From d942635085aedd869308e8fea804c8d5709fcd31 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 6 Jul 2026 19:41:55 +0200 Subject: [PATCH] fix(f4): harden schema-columns enrichment + ValidationError guard + staged-helper note --- .../__tests__/gate_schema_linking.test.js | 48 +++++++++++++++++++ harness/.pi/extensions/tht-gate.js | 19 ++++++-- harness/tht/cli/session_cmd.py | 8 +++- harness/tht/cli/sql_cmd.py | 2 + 4 files changed, 71 insertions(+), 6 deletions(-) diff --git a/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js b/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js index f234dcb6..fc20c978 100644 --- a/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js +++ b/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js @@ -120,3 +120,51 @@ test("reviewer_schema_linking records table/column decisions and syncs schema_li cp.execFileSync = origExecFileSync; } }); + +test("reviewer_schema_linking returns a textResult (not a throw) when a proposed table is not in the catalog", async () => { + const origExecFileSync = cp.execFileSync; + cp.execFileSync = (file, args) => { + if (args[0] === "phase" && args[1] === "meta") + return JSON.stringify({ phases: [{ num: 4, id: "F4" }] }); + if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 4\n"; + if (args[0] === "schema" && args[1] === "columns" && args[2] === "badtable") { + const err = new Error("Command failed"); + err.stderr = "ERRORE: tabella 'badtable' non trovata nel catalogo"; + throw err; + } + return ""; + }; + + try { + const gate = require(GATE); + const { createFakePi } = require("./fake_pi_runtime.js"); + const { pi, ctx, tools } = createFakePi(); + ctx.cwd = "/nonexistent-thothii-test-cwd"; + gate.default(pi); + + const tool = tools.get("reviewer_schema_linking"); + assert.ok(tool, "reviewer_schema_linking must be registered"); + + const result = await tool.def.execute( + "call-1", + { + session: "s1", + title: "Schema linking", + tables: [ + { + id: "t-bad", + name: "badtable", + kind: "promote", + }, + ], + }, + null, + null, + ctx, + ); + + assert.match(result.content[0].text, /badtable/); + } finally { + cp.execFileSync = origExecFileSync; + } +}); diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index 7d781fc8..efef59a8 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -651,10 +651,19 @@ export default function (pi) { const phase = phaseId(ctx, currentPhase(ctx, session)); // Enrich each table with its full catalog columns (deterministic source). - const enriched = tables.map((t) => { - const cat = JSON.parse(tht(ctx, ["schema", "columns", t.name, "--json"])); + const enriched = []; + for (const t of tables) { + let cat; + try { + cat = JSON.parse(tht(ctx, ["schema", "columns", t.name, "--json"])); + } catch (e) { + const msg = (e.stderr || e.message || String(e)).toString().trim(); + return textResult( + `Tabella '${t.name}' non caricabile dal catalogo (${msg}). Proponi solo tabelle presenti nel catalogo (usa 'tht schema render' / 'tht search' per verificarne i nomi).`, + ); + } const suggested = new Set(t.suggested_columns ?? []); - return { + enriched.push({ id: t.id, name: t.name, kind: t.kind, @@ -662,8 +671,8 @@ export default function (pi) { description: cat.description ?? "", rationale: t.rationale ?? "", columns: cat.columns.map((c) => ({ ...c, suggested: suggested.has(c.name) })), - }; - }); + }); + } const widget = buildSchemaLinkingRequest({ id: `u${Date.now()}`, diff --git a/harness/tht/cli/session_cmd.py b/harness/tht/cli/session_cmd.py index a2b5d314..01862561 100644 --- a/harness/tht/cli/session_cmd.py +++ b/harness/tht/cli/session_cmd.py @@ -147,11 +147,17 @@ def sync_schema_linking_cmd( config: Path = CONFIG_OPT, ) -> None: """Riproietta schema_linking.json dalle decisioni F4 del ledger (deterministico).""" + from pydantic import ValidationError + from tht.session.store import sync_schema_linking cfg = _load_config_or_exit(config) load_session_or_exit(cfg, session_id) - path = sync_schema_linking(session_id, cfg.paths.sessions) + try: + path = sync_schema_linking(session_id, cfg.paths.sessions) + except ValidationError as e: + typer.secho(f"ERRORE: schema_linking non valido:\n{e}", fg=typer.colors.RED, err=True) + raise typer.Exit(code=5) typer.secho(f"OK: schema_linking.json riproiettato ({path}).", fg=typer.colors.GREEN) diff --git a/harness/tht/cli/sql_cmd.py b/harness/tht/cli/sql_cmd.py index 2a3a7d7d..5fd1a0d3 100644 --- a/harness/tht/cli/sql_cmd.py +++ b/harness/tht/cli/sql_cmd.py @@ -54,6 +54,8 @@ def promoted_tables_for(cfg, session_id: str | None) -> set[str] | None: } +# Staged for a future SQL-projection consumer; hard column enforcement is a +# documented non-goal (Option-1 soft) — not yet wired into validate_or_exit. def promoted_columns_for(cfg, session_id: str | None) -> set[str] | None: if session_id is None: return None