chore(audit): spike del motore e seed della rubrica

- scripts/spike-audit.ts        lo spike che ha risposto alla domanda "la
                                verifica delle voci su un sito reale e'
                                affidabile e produce problemi concreti?".
                                Deliberatamente ISOLATO: non importa da src/,
                                non tocca il database, non tocca l'hub.
- scripts/seed-checklist.ts     emette SQL su stdout, cosi' il popolamento della
                                rubrica passa dalla stessa procedura SSH+docker
                                exec delle migration invece che da uno script
                                usa e getta puntato al DB di produzione.
- scripts/data/checklist.json   le 264 voci, 71 delle quali valgono anche per i
                                siti non-ecommerce.

.gitignore esclude spike-audit-*.json: sono i dati del sito di un cliente.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-08-18 17:48:34 +02:00
parent 08b0a60bae
commit 24213e7251
4 changed files with 3975 additions and 0 deletions
+177
View File
@@ -0,0 +1,177 @@
/**
* Semina `checklist_items` dalla rubrica in scripts/data/checklist.json.
*
* npx tsx scripts/seed-checklist.ts valida soltanto, non emette nulla
* npx tsx scripts/seed-checklist.ts --sql emette le INSERT su stdout
*
* Il Postgres di produzione non è esposto: l'SQL si applica via SSH + docker
* exec, come le migration. La diagnostica va su stderr apposta, così `--sql`
* si può reindirizzare senza sporcare l'output.
*
* La rubrica è la fonte di verità del MOTORE, non la struttura del documento:
* ogni voce è un'asserzione binaria e falsificabile che l'agent verifica sulle
* pagine scaricate. Nel documento non compare mai come elenco.
*
* IDEMPOTENTE, e per una ragione precisa: `audit_checklist_results` referenzia
* le voci con ON DELETE RESTRICT, perché un audit consegnato deve restare
* leggibile per sempre. Quindi le voci non si cancellano e non si ri-creano —
* si aggiornano in place. L'id è derivato deterministicamente da (step + testo)
* così una ri-esecuzione ritrova le stesse righe invece di duplicarle.
*
* Conseguenza da tenere a mente: cambiare il TESTO di una voce nel JSON la fa
* diventare una voce NUOVA (id diverso). È voluto — il testo è l'asserzione, e
* un'asserzione diversa è una domanda diversa. La vecchia resta, con la sua
* storia di risultati.
*/
import { createHash } from "node:crypto";
import { readFileSync, writeFileSync } from "node:fs";
import path from "node:path";
import type { AuditProfilo } from "@/db/schema";
type Voce = {
step: string;
sezione: string | null;
focus: string | null;
testo: string;
impatto_default: number | null;
confidenza_default: number | null;
registro: string;
profili: string[];
};
const REGISTRI = new Set(["volume", "premium", "neutro"]);
const PROFILI = new Set(["ecommerce", "servizi"]);
/** Id stabile fra esecuzioni: 21 caratteri come un nanoid, ma deterministico. */
function idVoce(step: string, testo: string): string {
return createHash("sha256")
.update(`${step}::${testo}`)
.digest("base64url")
.slice(0, 21);
}
async function main() {
const flag = process.argv.indexOf("--out");
const out = flag === -1 ? null : process.argv[flag + 1];
if (flag !== -1 && !out) throw new Error("--out richiede un percorso file");
const file = path.join(process.cwd(), "scripts", "data", "checklist.json");
const voci: Voce[] = JSON.parse(readFileSync(file, "utf8"));
if (!Array.isArray(voci) || voci.length === 0) {
throw new Error(`checklist.json vuoto o non è un array: ${file}`);
}
// Validazione prima di scrivere: un valore fuori dai CHECK del DB farebbe
// fallire la transazione a metà, dopo aver già scritto parte delle righe.
const visti = new Map<string, string>();
voci.forEach((v, i) => {
if (!v.step || !v.testo) throw new Error(`voce ${i}: step o testo mancante`);
if (!REGISTRI.has(v.registro)) {
throw new Error(`voce ${i}: registro "${v.registro}" non ammesso`);
}
if (!Array.isArray(v.profili) || v.profili.length === 0) {
throw new Error(`voce ${i}: profili vuoto`);
}
for (const p of v.profili) {
if (!PROFILI.has(p)) throw new Error(`voce ${i}: profilo "${p}" non ammesso`);
}
const id = idVoce(v.step, v.testo);
const gia = visti.get(id);
if (gia !== undefined) {
throw new Error(
`voci ${gia} e ${i} hanno lo stesso (step, testo) — collisione di id:\n ${v.testo}`
);
}
visti.set(id, String(i));
});
console.error(`${voci.length} voci lette, nessun duplicato.`);
const perStep = new Map<string, number>();
for (const v of voci) perStep.set(v.step, (perStep.get(v.step) ?? 0) + 1);
console.error(
[...perStep.entries()]
.sort((a, b) => b[1] - a[1])
.map(([s, n]) => ` ${s}: ${n}`)
.join("\n")
);
if (!out) {
console.error("\nNiente scritto. Usa --out <file> per generare le INSERT, es.:");
console.error(" npx tsx scripts/seed-checklist.ts --out /tmp/checklist.sql");
return;
}
// Si emette SQL invece di scrivere via Drizzle perché il Postgres di
// produzione non è esposto pubblicamente: l'unico percorso è SSH + docker
// exec, lo stesso delle migration.
const righe = voci.map((v, i) => {
const profili = v.profili as AuditProfilo[];
return [
q(idVoce(v.step, v.testo)),
`${q(JSON.stringify(profili))}::jsonb`,
q(v.step),
q(v.sezione),
q(v.focus),
q(v.testo),
n(v.impatto_default),
n(v.confidenza_default),
q(v.registro),
String(i),
].join(", ");
});
// ON CONFLICT DO UPDATE, mai DELETE: audit_checklist_results referenzia le
// voci con RESTRICT e un audit consegnato deve restare leggibile per sempre.
// Su file, non su stdout: la scrittura su pipe è asincrona e uscire prima
// del flush tronca l'SQL a metà riga.
writeFileSync(
out,
"-- Generato da scripts/seed-checklist.ts — non modificare a mano.\n" +
"INSERT INTO checklist_items\n" +
" (id, profili, step, sezione, focus, testo, impatto_default,\n" +
" confidenza_default, registro, sort_order)\nVALUES\n" +
righe.map((r) => ` (${r})`).join(",\n") +
"\nON CONFLICT (id) DO UPDATE SET\n" +
[
"profili",
"step",
"sezione",
"focus",
"testo",
"impatto_default",
"confidenza_default",
"registro",
"sort_order",
]
.map((c) => ` ${c} = EXCLUDED.${c}`)
.join(",\n") +
";\n"
);
console.error(`\nScritto ${out}. Applicare con:`);
console.error(
` cat ${out} | ssh root@… "docker exec -i … psql -U clienthub -d clienthub \\\n` +
` -v ON_ERROR_STOP=1 --single-transaction"`
);
}
/** Letterale stringa per Postgres, con gli apici raddoppiati. */
function q(v: string | null): string {
return v === null ? "NULL" : `'${v.replace(/'/g, "''")}'`;
}
function n(v: number | null): string {
return v === null ? "NULL" : String(v);
}
// NB: niente process.exit(0) in coda. Su stdout-pipe la scrittura è asincrona
// e uscire subito tronca l'SQL a metà riga — è successo davvero, e solo il
// --single-transaction di psql ha evitato un seed parziale in produzione.
// Si esce da soli quando i buffer sono vuoti; process.exit resta solo sull'errore.
main().catch((e) => {
console.error(e);
process.exit(1);
});