From ec9b12dff4c38b1a36777c20458afa8bddca5a52 Mon Sep 17 00:00:00 2001 From: mptyl Date: Mon, 20 Jul 2026 17:53:24 +0200 Subject: [PATCH] fix(harness): recover schema table name typos --- .../__tests__/gate_schema_linking.test.js | 93 +++++++++++++++++-- harness/.pi/extensions/tht-gate.js | 65 ++++++++++++- 2 files changed, 145 insertions(+), 13 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 c4e25580..8197421b 100644 --- a/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js +++ b/harness/.pi/extensions/gate/__tests__/gate_schema_linking.test.js @@ -4,8 +4,9 @@ // // HARNESS ADAPTATIONS (no existing test invokes a tool's execute() end-to-end): // 1. tht-gate.js uses ESM `import { execFileSync } from "node:child_process"`. Under -// CommonJS `require(esm)` the named binding tracks the module namespace, so patching -// `child_process.execFileSync` BEFORE requiring tht-gate.js is observed by tht(). +// CommonJS `require(esm)` the named binding is snapshotted at load time. A single +// delegating stub is installed ONCE before requiring tht-gate.js; each test swaps the +// inner handler via `_handler` so every stub is visible to the frozen ESM binding. // 2. tht()'s loadEnvFromDotenv calls `require("node:fs")`; that `require` is undefined // in the ESM module scope when loaded via require(esm) (in production Pi loads the // extension as CommonJS, where require is native). We install a globalThis.require @@ -33,12 +34,20 @@ const CATALOG = { ], }; +// Adaptation #1: the ESM import of execFileSync is frozen at require(GATE) time. +// A single delegating function is installed before the first load; each test swaps +// the inner _handler and clears it in finally — no save/restore of cp.execFileSync. +let _handler = null; +const _origExecFileSync = cp.execFileSync; +cp.execFileSync = function delegatingStub(file, args, opts) { + if (_handler) return _handler(file, args, opts); + return _origExecFileSync.call(cp, file, args, opts); +}; + test("reviewer_schema_linking records table/column decisions and syncs schema_linking", async () => { const calls = []; const inputs = []; - // Adaptation #1: stub the shell BEFORE requiring tht-gate.js. - const origExecFileSync = cp.execFileSync; - cp.execFileSync = (file, args, opts) => { + _handler = (file, args, opts) => { calls.push(args.join(" ")); inputs.push(opts?.input); if (args[0] === "phase" && args[1] === "meta") @@ -119,13 +128,79 @@ test("reviewer_schema_linking records table/column decisions and syncs schema_li assert.match(result.content[0].text, /Schema linking registrato/); } finally { - cp.execFileSync = origExecFileSync; + _handler = null; + } +}); + +test("reviewer_schema_linking auto-corrects typos in table names via fuzzy matching", async () => { + const calls = []; + const inputs = []; + _handler = (file, args, opts) => { + calls.push(args.join(" ")); + inputs.push(opts?.input); + 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"; + // The typo name fails: + if (args[0] === "schema" && args[1] === "columns" && args[2] === "dim_patientt") { + const err = new Error("Command failed"); + err.stderr = "ERRORE: tabella 'dim_patientt' non trovata nel catalogo"; + throw err; + } + // The correct name works: + if (args[0] === "schema" && args[1] === "columns" && args[2] === "dim_patient") + return JSON.stringify(CATALOG); + // catalogTableNames calls schema render: + if (args[0] === "schema" && args[1] === "render") + return "CREATE TABLE dim_patient (\n cod_paz bigint\n);\n"; + return ""; + }; + + try { + const gate = require(GATE); + const { createFakePi } = require("./fake_pi_runtime.js"); + const { pi, ctx, tools } = createFakePi(); + ctx.cwd = "/nonexistent-thothii-test-cwd-fuzzy"; // distinct key → fresh cache + gate.default(pi); + + let capturedDescriptor = null; + ctx.ui.input = async (title) => { + capturedDescriptor = JSON.parse(title); + return JSON.stringify({ + id: capturedDescriptor.id, + kind: "schema-linking", + tables: [{ id: "dim_patient", enacted: true, columns: ["cod_paz"] }], + }); + }; + + const tool = tools.get("reviewer_schema_linking"); + const result = await tool.def.execute( + "call-1", + { + session: "s1", + title: "Schema linking", + tables: [{ id: "t-pat", name: "dim_patientt", kind: "promote", suggested_columns: ["cod_paz"] }], + }, + null, + null, + ctx, + ); + + // The gate should have auto-corrected "dim_patientt" → "dim_patient". + assert.equal(capturedDescriptor.tables[0].name, "dim_patient"); + assert.match(result.content[0].text, /Schema linking registrato/); + // The persisted decisions use the corrected name. + const batchIdx = calls.findIndex((c) => c === "decision add-batch --session s1 --doc -"); + assert.ok(batchIdx !== -1, "expected atomic decision batch"); + const batch = JSON.parse(inputs[batchIdx]); + assert.equal(batch[0].subject, "dim_patient"); + } finally { + _handler = null; } }); 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) => { + _handler = (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"; @@ -167,6 +242,6 @@ test("reviewer_schema_linking returns a textResult (not a throw) when a proposed assert.match(result.content[0].text, /badtable/); } finally { - cp.execFileSync = origExecFileSync; + _handler = null; } }); diff --git a/harness/.pi/extensions/tht-gate.js b/harness/.pi/extensions/tht-gate.js index fc43906d..8f8643e6 100644 --- a/harness/.pi/extensions/tht-gate.js +++ b/harness/.pi/extensions/tht-gate.js @@ -367,6 +367,50 @@ function currentPhase(ctx, session) { return m ? parseInt(m[1], 10) : 1; } +// --- fuzzy table-name matching (prevents multi-turn debug loops on typos) --- + +function levenshtein(a, b) { + if (a === b) return 0; + const la = a.length, lb = b.length; + if (!la) return lb; + if (!lb) return la; + let prev = Array.from({ length: lb + 1 }, (_, i) => i); + for (let i = 1; i <= la; i++) { + const cur = [i]; + for (let j = 1; j <= lb; j++) { + cur[j] = Math.min(prev[j] + 1, cur[j - 1] + 1, prev[j - 1] + (a[i - 1] !== b[j - 1] ? 1 : 0)); + } + prev = cur; + } + return prev[lb]; +} + +const _catalogNamesCache = new Map(); +function catalogTableNames(ctx) { + const key = ctx.cwd ?? ""; + if (_catalogNamesCache.has(key)) return _catalogNamesCache.get(key); + let names; + try { + const raw = tht(ctx, ["schema", "render", "--format", "mschema-text"]); + names = [...raw.matchAll(/^CREATE TABLE (\S+)/gm)].map((m) => m[1]); + } catch { + names = []; + } + _catalogNamesCache.set(key, names); + return names; +} + +function closestTableName(ctx, name, maxDist = 3) { + const names = catalogTableNames(ctx); + let best = null, bestDist = maxDist + 1; + for (const n of names) { + if (Math.abs(n.length - name.length) > maxDist) continue; + const d = levenshtein(n, name); + if (d < bestDist) { bestDist = d; best = n; } + } + return best; +} + // tht phase advance --auto: exit 6 = not ready / needs human (silent no-op), others propagated. // Used for the fire-and-forget auto-advance of the auto phases (F2 memory, F6 cte) after a // reviewer_decide: it only advances when the phase is auto-eligible (zero substantive @@ -978,10 +1022,23 @@ export default function (pi) { 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).`, - ); + // Fuzzy recovery: the model often misspells Italian table names + // (e.g. "abellazione" vs "ablazione"). Auto-correct if a close + // catalog match exists, preventing a multi-turn debug spiral. + const fix = closestTableName(ctx, t.name); + if (fix) { + try { + cat = JSON.parse(tht(ctx, ["schema", "columns", fix, "--json"])); + t.id = fix; + t.name = fix; + } catch { /* fall through to error */ } + } + if (!cat) { + 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).`, + ); + } } if (!Array.isArray(cat.columns)) { return textResult(`Catalogo per '${t.name}' non contiene colonne valide. Verifica con 'tht schema columns ${t.name} --json'.`);