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>
158 lines
6.0 KiB
JavaScript
158 lines
6.0 KiB
JavaScript
// 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);
|
|
});
|