feat(gate): deterministic v2 review-gate payloads (cte_plan/cte_result/phase)

The tht-gate.js reviewer_confirm now builds structured v2 artifacts before the
widget so the reviewer approves gate-derived data, not raw model text:

- new pure modules gate/artifact-contracts.js (soft validators, {ok,errors},
  legacy-passthrough) and gate/enrich.js (index/description enrichment,
  buildCteResultV2 fusing thin model data with `tht cte info`, phase enrichment)
- cte_plan v2: validate + enrich + persist via `tht cte plan --name … --doc -`
  (names derived from data.ctes[]); legacy `names` param kept as fallback
- cte_result v2: rebuild from `tht cte next`/`tht cte info` (sql + preview from
  the persisted test record); null/error last_test -> actionable textResult
- phase v2: soft-validate + fill phase from meta + catalog descriptions
- prepareReviewerArguments coerces artifact.data too (GLM double-stringify);
  legacy markdown strings pass through unchanged
- SKILL.md: Phase 6 cte_plan payload A + thin cte_result guidance; Discipline 6
  payload C example; Discipline 7 reworded for gate-rebuilt cte_result

Legacy (non-v2) paths unchanged. TypeBox stays Type.Any() for artifact.data;
validation is soft (textResult) so models self-correct instead of looping.
All 102 gate JS tests green.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
2026-07-07 00:42:48 +02:00
co-authored by Claude Fable 5
parent 9b4f6b9804
commit 3ad93cd02f
8 changed files with 1278 additions and 18 deletions
@@ -0,0 +1,157 @@
// Soft-validators for the v2 artifact payloads (contracts.md A/B/C). Pure, no I/O.
// A payload without schema_version===2 is NOT an error -- it's legacy, and the caller
// falls back to the current (pre-v2) rendering path.
const test = require("node:test");
const assert = require("node:assert");
const {
validateCtePlanV2,
validateCteResultThin,
validatePhaseSummaryV2,
} = require("../artifact-contracts.js");
// --- validateCtePlanV2 (payload A) ------------------------------------------------
test("validateCtePlanV2: legacy (no schema_version) is ok:true, legacy:true", () => {
const out = validateCtePlanV2({ some: "legacy shape" });
assert.deepEqual(out, { ok: true, legacy: true });
});
test("validateCtePlanV2: legacy when data is a plain string (markdown)", () => {
const out = validateCtePlanV2("### Piano CTE\n1. foo");
assert.deepEqual(out, { ok: true, legacy: true });
});
test("validateCtePlanV2: valid v2 payload", () => {
const out = validateCtePlanV2({
schema_version: 2,
question: "domanda",
strategy: "razionale",
ctes: [
{ name: "base_pazienti", purpose: "p", rationale: "r", depends_on: [], tables: [], keys: [], filters: [], output_columns: [] },
{ name: "eventi", purpose: "p2", rationale: "r2", depends_on: ["base_pazienti"], tables: [], keys: [], filters: [], output_columns: [] },
],
});
assert.equal(out.ok, true);
assert.ok(!out.errors || out.errors.length === 0);
});
test("validateCtePlanV2: rejects missing ctes array", () => {
const out = validateCtePlanV2({ schema_version: 2, question: "q", strategy: "s" });
assert.equal(out.ok, false);
assert.ok(out.errors.some((e) => /ctes/.test(e)));
});
test("validateCtePlanV2: rejects a CTE missing name", () => {
const out = validateCtePlanV2({
schema_version: 2, question: "q", strategy: "s",
ctes: [{ purpose: "p" }],
});
assert.equal(out.ok, false);
assert.ok(out.errors.some((e) => /ctes\[0\].*name/.test(e)));
});
test("validateCtePlanV2: rejects depends_on referencing an unknown/forward CTE", () => {
const out = validateCtePlanV2({
schema_version: 2, question: "q", strategy: "s",
ctes: [
{ name: "a", depends_on: ["b"] }, // b does not precede a
{ name: "b", depends_on: [] },
],
});
assert.equal(out.ok, false);
assert.ok(out.errors.some((e) => /depends_on/.test(e) && /'b'/.test(e)));
});
test("validateCtePlanV2: depends_on referencing an earlier CTE is fine", () => {
const out = validateCtePlanV2({
schema_version: 2, question: "q", strategy: "s",
ctes: [
{ name: "a", depends_on: [] },
{ name: "b", depends_on: ["a"] },
],
});
assert.equal(out.ok, true);
});
// --- validateCteResultThin (payload B, model side is THIN) ------------------------
test("validateCteResultThin: legacy (string/markdown) is ok:true, legacy:true", () => {
assert.deepEqual(validateCteResultThin("**CTE risultato**: ok"), { ok: true, legacy: true });
assert.deepEqual(validateCteResultThin({ some: "legacy" }), { ok: true, legacy: true });
});
test("validateCteResultThin: valid thin v2 payload (all fields optional)", () => {
const out = validateCteResultThin({ schema_version: 2 });
assert.equal(out.ok, true);
});
test("validateCteResultThin: valid thin v2 payload with purpose/rationale/note", () => {
const out = validateCteResultThin({
schema_version: 2, purpose: "seleziona pazienti", rationale: "base per la catena", note: "ok",
});
assert.equal(out.ok, true);
});
test("validateCteResultThin: rejects non-string purpose/rationale/note", () => {
const out = validateCteResultThin({ schema_version: 2, purpose: 42 });
assert.equal(out.ok, false);
assert.ok(out.errors.some((e) => /purpose/.test(e)));
});
test("validateCteResultThin: permissive on extra fields (e.g. a model that also sends sql) -- guidance against this lives in SKILL.md, not the validator", () => {
const out = validateCteResultThin({ schema_version: 2, sql: "WITH a AS (SELECT 1)" });
assert.equal(out.ok, true);
});
// --- validatePhaseSummaryV2 (payload C) -------------------------------------------
test("validatePhaseSummaryV2: legacy (no schema_version) is ok:true, legacy:true", () => {
assert.deepEqual(validatePhaseSummaryV2({ recap: "legacy text" }), { ok: true, legacy: true });
assert.deepEqual(validatePhaseSummaryV2("legacy markdown"), { ok: true, legacy: true });
});
test("validatePhaseSummaryV2: valid minimal v2 payload", () => {
const out = validatePhaseSummaryV2({ schema_version: 2, summary: "riepilogo" });
assert.equal(out.ok, true);
});
test("validatePhaseSummaryV2: valid full v2 payload", () => {
const out = validatePhaseSummaryV2({
schema_version: 2,
summary: "riepilogo",
checks: [{ label: "schema valido", status: "ok" }],
sections: [{ title: "Criteri", items: [{ label: "flag attivo", table: "t", column: "c", value: "TRUE", kind: "filter" }] }],
tables: [{ name: "t", role: "promoted", columns: [{ name: "c" }] }],
open_questions: [],
});
assert.equal(out.ok, true);
});
test("validatePhaseSummaryV2: rejects missing summary", () => {
const out = validatePhaseSummaryV2({ schema_version: 2 });
assert.equal(out.ok, false);
assert.ok(out.errors.some((e) => /summary/.test(e)));
});
test("validatePhaseSummaryV2: rejects a check with an invalid status", () => {
const out = validatePhaseSummaryV2({
schema_version: 2, summary: "s",
checks: [{ label: "x", status: "maybe" }],
});
assert.equal(out.ok, false);
assert.ok(out.errors.some((e) => /checks\[0\].*status/.test(e)));
});
test("validatePhaseSummaryV2: rejects a section missing title or items", () => {
const out = validatePhaseSummaryV2({
schema_version: 2, summary: "s",
sections: [{ items: [] }],
});
assert.equal(out.ok, false);
assert.ok(out.errors.some((e) => /sections\[0\].*title/.test(e)));
});
test("validatePhaseSummaryV2: permissive on extra unknown fields", () => {
const out = validatePhaseSummaryV2({ schema_version: 2, summary: "s", extra_field_from_model: "whatever" });
assert.equal(out.ok, true);
});
@@ -0,0 +1,192 @@
// Pure merge/enrichment functions (contracts.md A/B/C). Catalog lookups are injected
// as a `getColumns` function parameter so these are testable with no `tht` shell-out.
const test = require("node:test");
const assert = require("node:assert");
const {
enrichCtePlanV2,
buildCteResultV2,
enrichPhaseSummaryV2,
} = require("../enrich.js");
// --- enrichCtePlanV2 --------------------------------------------------------------
test("enrichCtePlanV2 assigns 1-based index in ctes[] order", () => {
const data = {
schema_version: 2, question: "q", strategy: "s",
ctes: [
{ name: "a", tables: [], filters: [] },
{ name: "b", tables: [], filters: [] },
],
};
const out = enrichCtePlanV2(data, () => null);
assert.equal(out.ctes[0].index, 1);
assert.equal(out.ctes[1].index, 2);
});
test("enrichCtePlanV2 fills tables[].description from the catalog", () => {
const getColumns = (table) =>
table === "dim_patient" ? { description: "Anagrafica pazienti", columns: [{ name: "cod_paz", description: "Codice paziente" }] } : null;
const data = {
schema_version: 2, question: "q", strategy: "s",
ctes: [{ name: "a", tables: [{ name: "dim_patient" }], filters: [] }],
};
const out = enrichCtePlanV2(data, getColumns);
assert.equal(out.ctes[0].tables[0].description, "Anagrafica pazienti");
});
test("enrichCtePlanV2 fills filters[].description by resolving table.column", () => {
const getColumns = (table) =>
table === "dim_patient"
? { description: "Anagrafica", columns: [{ name: "flag_attivo", description: "Flag attivo" }] }
: null;
const data = {
schema_version: 2, question: "q", strategy: "s",
ctes: [{ name: "a", tables: [], filters: [{ column: "dim_patient.flag_attivo", op: "IS", value: "TRUE" }] }],
};
const out = enrichCtePlanV2(data, getColumns);
assert.equal(out.ctes[0].filters[0].description, "Flag attivo");
});
test("enrichCtePlanV2: catalog miss -> empty description string, never throws", () => {
const out = enrichCtePlanV2(
{
schema_version: 2, question: "q", strategy: "s",
ctes: [{ name: "a", tables: [{ name: "nonexistent_table" }], filters: [{ column: "nonexistent_table.x", op: "IS", value: "1" }] }],
},
() => null,
);
assert.equal(out.ctes[0].tables[0].description, "");
assert.equal(out.ctes[0].filters[0].description, "");
});
test("enrichCtePlanV2: getColumns throwing is tolerated by the caller contract (function itself does not catch)", () => {
// enrich.js documents that getColumns must itself be safe (try/catch -> null on miss);
// enrichCtePlanV2 does not need its own try/catch around calls, since getColumns never throws.
const getColumns = () => null; // simulates the safe wrapper's miss behavior
const out = enrichCtePlanV2(
{ schema_version: 2, question: "q", strategy: "s", ctes: [{ name: "a", tables: [{ name: "t" }], filters: [] }] },
getColumns,
);
assert.equal(out.ctes[0].tables[0].description, "");
});
test("enrichCtePlanV2 preserves model-authored fields (purpose/rationale/depends_on/keys/output_columns)", () => {
const data = {
schema_version: 2, question: "q", strategy: "s",
ctes: [{
name: "a", purpose: "scopo", rationale: "motivo", depends_on: [],
keys: ["cod_paz"], output_columns: ["cod_paz"], tables: [], filters: [],
}],
};
const out = enrichCtePlanV2(data, () => null);
assert.equal(out.ctes[0].purpose, "scopo");
assert.equal(out.ctes[0].rationale, "motivo");
assert.deepEqual(out.ctes[0].keys, ["cod_paz"]);
assert.deepEqual(out.ctes[0].output_columns, ["cod_paz"]);
});
// --- buildCteResultV2 --------------------------------------------------------------
test("buildCteResultV2 merges name/index/total/sql from cteInfo", () => {
const thin = { schema_version: 2, note: "nota" };
const cteInfo = {
name: "base_pazienti", index: 1, total: 3, sql: "WITH base_pazienti AS (SELECT 1)",
doc: null, last_test: { status: "ok", execution_ms: 42, row_sample: 5, warnings: [], sql_hash: "h1", columns: ["x"], preview_rows: [[1]] },
};
const out = buildCteResultV2(thin, cteInfo);
assert.equal(out.name, "base_pazienti");
assert.equal(out.index, 1);
assert.equal(out.total, 3);
assert.equal(out.sql, "WITH base_pazienti AS (SELECT 1)");
assert.equal(out.note, "nota");
});
test("buildCteResultV2 prefers doc purpose/rationale/depends_on, falls back to thin data", () => {
const cteInfoWithDoc = {
name: "a", index: 1, total: 1, sql: "WITH a AS (SELECT 1)",
doc: { purpose: "scopo dal doc", rationale: "motivo dal doc", depends_on: ["x"] },
last_test: { status: "ok", execution_ms: 1, row_sample: 0, warnings: [], sql_hash: "h", columns: [], preview_rows: [] },
};
const out1 = buildCteResultV2({ schema_version: 2, purpose: "scopo thin" }, cteInfoWithDoc);
assert.equal(out1.purpose, "scopo dal doc");
assert.equal(out1.rationale, "motivo dal doc");
assert.deepEqual(out1.depends_on, ["x"]);
const cteInfoNoDoc = { ...cteInfoWithDoc, doc: null };
const out2 = buildCteResultV2({ schema_version: 2, purpose: "scopo thin", rationale: "motivo thin" }, cteInfoNoDoc);
assert.equal(out2.purpose, "scopo thin");
assert.equal(out2.rationale, "motivo thin");
});
test("buildCteResultV2 builds preview from last_test.columns/preview_rows", () => {
const cteInfo = {
name: "a", index: 1, total: 1, sql: "WITH a AS (SELECT 1)", doc: null,
last_test: { status: "ok", execution_ms: 10, row_sample: 2, warnings: [], sql_hash: "h1", columns: ["cod_paz", "eta"], preview_rows: [[1, 30], [2, 45]] },
};
const out = buildCteResultV2({ schema_version: 2 }, cteInfo);
assert.deepEqual(out.preview, { columns: ["cod_paz", "eta"], rows: [[1, 30], [2, 45]] });
assert.equal(out.status, "ok");
assert.equal(out.execution_ms, 10);
assert.equal(out.row_sample, 2);
assert.equal(out.sql_hash, "h1");
assert.deepEqual(out.warnings, []);
});
test("buildCteResultV2: columns[] carries name, with description left as an empty placeholder (filled separately by the gate)", () => {
const cteInfo = {
name: "a", index: 1, total: 1, sql: "WITH a AS (SELECT 1)", doc: null,
last_test: { status: "ok", execution_ms: 1, row_sample: 1, warnings: [], sql_hash: "h", columns: ["cod_paz"], preview_rows: [[1]] },
};
const out = buildCteResultV2({ schema_version: 2 }, cteInfo);
assert.equal(out.columns.length, 1);
assert.equal(out.columns[0].name, "cod_paz");
assert.equal(out.columns[0].description, "");
});
// --- enrichPhaseSummaryV2 -----------------------------------------------------------
test("enrichPhaseSummaryV2 fills phase from phaseMeta", () => {
const data = { schema_version: 2, summary: "riepilogo" };
const phaseMeta = { id: "F5", num: 5, name: "sintesi" };
const out = enrichPhaseSummaryV2(data, phaseMeta, () => null);
assert.deepEqual(out.phase, { id: "F5", num: 5, name: "sintesi" });
});
test("enrichPhaseSummaryV2 enriches tables[]/tables[].columns[]/sections[].items[] descriptions", () => {
const getColumns = (table) =>
table === "dim_patient"
? { description: "Anagrafica", columns: [{ name: "cod_paz", description: "Codice paziente" }] }
: null;
const data = {
schema_version: 2, summary: "s",
tables: [{ name: "dim_patient", role: "promoted", columns: [{ name: "cod_paz" }] }],
sections: [{ title: "Criteri", items: [{ label: "id paziente", table: "dim_patient", column: "cod_paz" }] }],
};
const out = enrichPhaseSummaryV2(data, { id: "F5", num: 5, name: "sintesi" }, getColumns);
assert.equal(out.tables[0].description, "Anagrafica");
assert.equal(out.tables[0].columns[0].description, "Codice paziente");
assert.equal(out.sections[0].items[0].description, "Codice paziente");
});
test("enrichPhaseSummaryV2: catalog miss -> empty description, never throws", () => {
const data = {
schema_version: 2, summary: "s",
tables: [{ name: "nope", role: "excluded", columns: [{ name: "x" }] }],
sections: [{ title: "t", items: [{ label: "l", table: "nope", column: "x" }] }],
};
const out = enrichPhaseSummaryV2(data, { id: "F5", num: 5, name: "sintesi" }, () => null);
assert.equal(out.tables[0].description, "");
assert.equal(out.tables[0].columns[0].description, "");
assert.equal(out.sections[0].items[0].description, "");
});
test("enrichPhaseSummaryV2 preserves open_questions/checks untouched", () => {
const data = {
schema_version: 2, summary: "s",
checks: [{ label: "x", status: "ok" }],
open_questions: ["domanda aperta"],
};
const out = enrichPhaseSummaryV2(data, { id: "F1", num: 1, name: "chiarimento" }, () => null);
assert.deepEqual(out.checks, [{ label: "x", status: "ok" }]);
assert.deepEqual(out.open_questions, ["domanda aperta"]);
});
@@ -0,0 +1,420 @@
// L1.5 roundtrip for reviewer_confirm's v2 payloads (cte_plan / cte_result / phase),
// driven end-to-end against a fake Pi runtime with `tht` (execFileSync) stubbed.
// Same harness adaptations as gate_roundtrip.test.js / gate_schema_linking.test.js:
// ESM-loaded-via-require + a globalThis.require shim for loadEnvFromDotenv.
const test = require("node:test");
const assert = require("node:assert");
const cp = require("node:child_process");
const { createRequire } = require("node:module");
const path = require("node:path");
const GATE = path.join(__dirname, "..", "..", "tht-gate.js");
if (typeof globalThis.require === "undefined") {
globalThis.require = createRequire(GATE);
}
// tht-gate.js binds `execFileSync` at module-eval time: an ESM `import` over a
// CommonJS require is a SNAPSHOT, not a live binding — reassigning cp.execFileSync
// AFTER the first gate load is invisible to the gate, and ESM modules can't be
// dropped from the require cache. So we install a PERMANENT dispatcher on `cp`
// exactly once (before the first gate load) that forwards to a mutable `activeStub`,
// and intercept every `cp.execFileSync = fn` assignment (via a property setter) to
// swap `activeStub` instead of clobbering the dispatcher. Each test's existing
// `cp.execFileSync = fn` / restore-in-finally then Just Works.
let activeStub = cp.execFileSync;
const dispatcher = (...args) => activeStub(...args);
Object.defineProperty(cp, "execFileSync", {
configurable: true,
get: () => dispatcher,
set: (fn) => { activeStub = fn; },
});
async function loadGate() {
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);
// Reset the module-level _phaseMetaCache so an earlier test's `tht phase meta`
// stub (with a different phase num) does not leak into this test.
await pi.emit("session_start", {});
return { ctx, tools };
}
function approveVia(ctx, respBuilder) {
ctx.ui.input = async (title) => {
const descriptor = JSON.parse(title);
return JSON.stringify(respBuilder(descriptor));
};
}
const CATALOG = {
dim_patient: {
table: "dim_patient",
description: "Anagrafica pazienti",
columns: [
{ name: "cod_paz", description: "Codice paziente", type: "bigint", pk: true },
{ name: "flag_attivo", description: "Flag attivo", type: "boolean", pk: false },
],
},
};
// --- kind: cte_plan v2 --------------------------------------------------------------
test("reviewer_confirm kind:cte_plan (v2): enriches via catalog, derives names from ctes[], persists via `tht cte plan --doc -`", async () => {
const calls = [];
const origExecFileSync = cp.execFileSync;
cp.execFileSync = (file, args, opts) => {
calls.push({ args: args.join(" "), input: opts && opts.input });
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 6, id: "F6", name: "cte" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 6\n";
if (args[0] === "schema" && args[1] === "columns")
return JSON.stringify(CATALOG[args[2]] || {});
return "";
};
try {
const { ctx, tools } = await loadGate();
approveVia(ctx, (d) => ({ id: d.id, choices: ["approve"] }));
const tool = tools.get("reviewer_confirm");
assert.ok(tool, "reviewer_confirm must be registered");
const artifactData = {
schema_version: 2,
question: "domanda riscritta",
strategy: "razionale della catena",
ctes: [
{ name: "base_pazienti", purpose: "seleziona pazienti attivi", rationale: "base", depends_on: [], tables: [{ name: "dim_patient" }], filters: [{ column: "dim_patient.flag_attivo", op: "IS", value: "TRUE" }], keys: ["cod_paz"], output_columns: ["cod_paz"] },
],
};
const result = await tool.def.execute(
"call-1",
{
session: "s1",
kind: "cte_plan",
title: "Piano CTE",
artifact: { kind: "cte_plan", data: artifactData },
},
null, null, ctx,
);
const planCall = calls.find((c) => c.args.startsWith("cte plan --session s1"));
assert.ok(planCall, `expected a 'cte plan' call in: ${JSON.stringify(calls.map((c) => c.args))}`);
assert.match(planCall.args, /--name base_pazienti/);
assert.match(planCall.args, /--doc -/);
const pipedDoc = JSON.parse(planCall.input);
assert.equal(pipedDoc.ctes[0].tables[0].description, "Anagrafica pazienti");
assert.equal(pipedDoc.ctes[0].filters[0].description, "Flag attivo");
assert.equal(pipedDoc.ctes[0].index, 1);
assert.match(result.content[0].text, /CTE plan approvato/);
} finally {
cp.execFileSync = origExecFileSync;
}
});
test("reviewer_confirm kind:cte_plan (v2): invalid payload returns textResult with errors (model self-corrects), never throws", async () => {
const origExecFileSync = cp.execFileSync;
cp.execFileSync = (file, args) => {
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 6, id: "F6", name: "cte" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 6\n";
return "";
};
try {
const { ctx, tools } = await loadGate();
const tool = tools.get("reviewer_confirm");
const result = await tool.def.execute(
"call-1",
{
session: "s1",
kind: "cte_plan",
title: "Piano CTE",
artifact: { kind: "cte_plan", data: { schema_version: 2, question: "q", strategy: "s" } }, // missing ctes
},
null, null, ctx,
);
assert.match(result.content[0].text, /ctes/);
} finally {
cp.execFileSync = origExecFileSync;
}
});
test("reviewer_confirm kind:cte_plan legacy (non-v2 data): unchanged path via params.names", async () => {
const calls = [];
const origExecFileSync = cp.execFileSync;
cp.execFileSync = (file, args) => {
calls.push(args.join(" "));
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 6, id: "F6", name: "cte" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 6\n";
return "";
};
try {
const { ctx, tools } = await loadGate();
approveVia(ctx, (d) => ({ id: d.id, choices: ["approve"] }));
const tool = tools.get("reviewer_confirm");
const result = await tool.def.execute(
"call-1",
{
session: "s1",
kind: "cte_plan",
title: "Piano CTE",
artifact: { kind: "cte_plan", data: "### Piano CTE legacy" },
names: ["a", "b"],
},
null, null, ctx,
);
assert.ok(calls.some((c) => c === "cte plan --session s1 --name a --name b"));
assert.match(result.content[0].text, /CTE plan approvato/);
} finally {
cp.execFileSync = origExecFileSync;
}
});
// --- kind: cte_result v2 -------------------------------------------------------------
test("reviewer_confirm kind:cte_result (v2 thin): builds full payload via `tht cte next`/`tht cte info`, enriches columns, emitted descriptor has sql + preview", async () => {
const calls = [];
const origExecFileSync = cp.execFileSync;
cp.execFileSync = (file, args) => {
calls.push(args.join(" "));
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 6, id: "F6", name: "cte" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 6\n";
if (args[0] === "cte" && args[1] === "next") return "base_pazienti\n";
if (args[0] === "cte" && args[1] === "info")
return JSON.stringify({
name: "base_pazienti", index: 1, total: 2,
plan: ["base_pazienti", "eventi"],
sql: "WITH base_pazienti AS (SELECT cod_paz FROM dim_patient WHERE flag_attivo)",
approved: false,
doc: { purpose: "seleziona pazienti", rationale: "base", depends_on: [] },
last_test: {
name: "base_pazienti", ts: "2026-01-01T00:00:00Z", sql_hash: "h1", status: "ok",
columns: ["cod_paz"], row_sample: 1, execution_ms: 12, warnings: [], preview_rows: [[1]],
},
});
if (args[0] === "schema" && args[1] === "columns")
return JSON.stringify(CATALOG[args[2]] || {});
return "";
};
try {
const { ctx, tools } = await loadGate();
let capturedDescriptor = null;
ctx.ui.input = async (title) => {
capturedDescriptor = JSON.parse(title);
return JSON.stringify({ id: capturedDescriptor.id, choices: ["approve"] });
};
const tool = tools.get("reviewer_confirm");
const result = await tool.def.execute(
"call-1",
{
session: "s1",
kind: "cte_result",
title: "Esito CTE",
artifact: { kind: "cte_result", data: { schema_version: 2, note: "prima passata ok" } },
},
null, null, ctx,
);
assert.equal(capturedDescriptor.artifact.data.sql, "WITH base_pazienti AS (SELECT cod_paz FROM dim_patient WHERE flag_attivo)");
assert.deepEqual(capturedDescriptor.artifact.data.preview, { columns: ["cod_paz"], rows: [[1]] });
assert.equal(capturedDescriptor.artifact.data.name, "base_pazienti");
assert.ok(calls.some((c) => c === "cte next --session s1"));
assert.ok(calls.some((c) => c.startsWith("cte info base_pazienti --session s1")));
assert.match(result.content[0].text, /base_pazienti.*approvato/s);
} finally {
cp.execFileSync = origExecFileSync;
}
});
test("reviewer_confirm kind:cte_result (v2): last_test null -> clear textResult error, no widget shown", async () => {
const origExecFileSync = cp.execFileSync;
cp.execFileSync = (file, args) => {
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 6, id: "F6", name: "cte" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 6\n";
if (args[0] === "cte" && args[1] === "next") return "base_pazienti\n";
if (args[0] === "cte" && args[1] === "info")
return JSON.stringify({
name: "base_pazienti", index: 1, total: 1, plan: ["base_pazienti"],
sql: "WITH base_pazienti AS (SELECT 1)", approved: false, doc: null, last_test: null,
});
return "";
};
try {
const { ctx, tools } = await loadGate();
let uiCalled = false;
ctx.ui.input = async () => { uiCalled = true; return undefined; };
const tool = tools.get("reviewer_confirm");
const result = await tool.def.execute(
"call-1",
{
session: "s1",
kind: "cte_result",
title: "Esito CTE",
artifact: { kind: "cte_result", data: { schema_version: 2 } },
},
null, null, ctx,
);
assert.equal(uiCalled, false, "the gate must not present a widget without a test outcome");
assert.match(result.content[0].text, /tht cte test/);
} finally {
cp.execFileSync = origExecFileSync;
}
});
test("reviewer_confirm kind:cte_result (v2): last_test status error -> clear textResult error", async () => {
const origExecFileSync = cp.execFileSync;
cp.execFileSync = (file, args) => {
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 6, id: "F6", name: "cte" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 6\n";
if (args[0] === "cte" && args[1] === "next") return "base_pazienti\n";
if (args[0] === "cte" && args[1] === "info")
return JSON.stringify({
name: "base_pazienti", index: 1, total: 1, plan: ["base_pazienti"],
sql: "WITH base_pazienti AS (SELECT 1)", approved: false, doc: null,
last_test: { name: "base_pazienti", ts: "t", sql_hash: "h", status: "error", error: "colonna inesistente" },
});
return "";
};
try {
const { ctx, tools } = await loadGate();
let uiCalled = false;
ctx.ui.input = async () => { uiCalled = true; return undefined; };
const tool = tools.get("reviewer_confirm");
const result = await tool.def.execute(
"call-1",
{
session: "s1", kind: "cte_result", title: "Esito CTE",
artifact: { kind: "cte_result", data: { schema_version: 2 } },
},
null, null, ctx,
);
assert.equal(uiCalled, false);
assert.match(result.content[0].text, /tht cte test/);
} finally {
cp.execFileSync = origExecFileSync;
}
});
test("reviewer_confirm kind:cte_result legacy (markdown string data): unchanged path (cte_approved by next name)", async () => {
const calls = [];
const origExecFileSync = cp.execFileSync;
cp.execFileSync = (file, args) => {
calls.push(args.join(" "));
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 6, id: "F6", name: "cte" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 6\n";
if (args[0] === "cte" && args[1] === "next") return "base_pazienti\n";
return "";
};
try {
const { ctx, tools } = await loadGate();
approveVia(ctx, (d) => ({ id: d.id, choices: ["approve"] }));
const tool = tools.get("reviewer_confirm");
const result = await tool.def.execute(
"call-1",
{
session: "s1", kind: "cte_result", title: "Esito CTE",
artifact: { kind: "cte_result", data: "### Esito CTE\nOK, 5 righe." },
},
null, null, ctx,
);
assert.ok(calls.some((c) => c === "decision add --session s1 --type cte_approved --subject base_pazienti"));
assert.match(result.content[0].text, /base_pazienti.*approvato/);
} finally {
cp.execFileSync = origExecFileSync;
}
});
// --- kind: phase v2 --------------------------------------------------------------
test("reviewer_confirm kind:phase (v2): fills phase from phaseMeta and enriches descriptions", async () => {
const origExecFileSync = cp.execFileSync;
cp.execFileSync = (file, args) => {
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 5, id: "F5", name: "sintesi" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 5\n";
if (args[0] === "phase" && args[1] === "advance") return "";
if (args[0] === "schema" && args[1] === "columns")
return JSON.stringify(CATALOG[args[2]] || {});
return "";
};
try {
const { ctx, tools } = await loadGate();
let capturedDescriptor = null;
ctx.ui.input = async (title) => {
capturedDescriptor = JSON.parse(title);
return JSON.stringify({ id: capturedDescriptor.id, choices: ["approve"] });
};
const tool = tools.get("reviewer_confirm");
const result = await tool.def.execute(
"call-1",
{
session: "s1", kind: "phase", title: "Chiusura fase",
artifact: {
kind: "phase",
data: {
schema_version: 2,
summary: "riepilogo sintesi",
tables: [{ name: "dim_patient", role: "promoted", columns: [{ name: "cod_paz" }] }],
},
},
},
null, null, ctx,
);
assert.deepEqual(capturedDescriptor.artifact.data.phase, { num: 5, id: "F5", name: "sintesi" });
assert.equal(capturedDescriptor.artifact.data.tables[0].description, "Anagrafica pazienti");
assert.equal(capturedDescriptor.artifact.data.tables[0].columns[0].description, "Codice paziente");
assert.match(result.content[0].text, /Fase approvata/);
} finally {
cp.execFileSync = origExecFileSync;
}
});
test("reviewer_confirm kind:phase legacy (non-v2 data): unchanged path", async () => {
const origExecFileSync = cp.execFileSync;
cp.execFileSync = (file, args) => {
if (args[0] === "phase" && args[1] === "meta")
return JSON.stringify({ phases: [{ num: 1, id: "F1", name: "chiarimento" }] });
if (args[0] === "phase" && args[1] === "show") return "Fase corrente: 1\n";
if (args[0] === "phase" && args[1] === "advance") return "";
return "";
};
try {
const { ctx, tools } = await loadGate();
let capturedDescriptor = null;
ctx.ui.input = async (title) => {
capturedDescriptor = JSON.parse(title);
return JSON.stringify({ id: capturedDescriptor.id, choices: ["approve"] });
};
const tool = tools.get("reviewer_confirm");
const result = await tool.def.execute(
"call-1",
{
session: "s1", kind: "phase", title: "Chiusura fase",
artifact: { kind: "phase", data: { recap: "legacy recap text" } },
},
null, null, ctx,
);
assert.deepEqual(capturedDescriptor.artifact.data, { recap: "legacy recap text" });
assert.match(result.content[0].text, /Fase approvata/);
} finally {
cp.execFileSync = origExecFileSync;
}
});
@@ -46,6 +46,33 @@ test("prepareReviewerArguments parses stringified tables array", () => {
assert.equal(out.tables[0].id, "t1");
});
// WS2: GLM sometimes double-stringifies -- artifact.data itself arrives as a JSON
// string even after artifact is (or already was) an object. Coerce it too.
test("prepareReviewerArguments coerces a stringified artifact.data into an object (GLM case)", () => {
const out = prepareReviewerArguments({
artifact: { kind: "cte_plan", data: JSON.stringify({ schema_version: 2, ctes: [] }) },
});
assert.equal(typeof out.artifact.data, "object");
assert.equal(out.artifact.data.schema_version, 2);
assert.deepEqual(out.artifact.data.ctes, []);
});
test("prepareReviewerArguments coerces artifact.data even when artifact itself arrived stringified", () => {
const out = prepareReviewerArguments({
artifact: JSON.stringify({ kind: "phase", data: JSON.stringify({ schema_version: 2, summary: "s" }) }),
});
assert.equal(typeof out.artifact, "object");
assert.equal(typeof out.artifact.data, "object");
assert.equal(out.artifact.data.summary, "s");
});
test("prepareReviewerArguments leaves a legacy markdown string artifact.data UNCHANGED", () => {
const out = prepareReviewerArguments({
artifact: { kind: "cte_result", data: "### Risultato CTE\nOK, 5 righe." },
});
assert.equal(out.artifact.data, "### Risultato CTE\nOK, 5 righe.");
});
test("GATE_CODE_FILES blocks writes to gate extensions, not session artifacts", () => {
assert.ok(GATE_CODE_FILES.test("harness/.pi/extensions/tht-gate.js"));
assert.ok(GATE_CODE_FILES.test(".pi/extensions/reserved-labels.mjs"));