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:
@@ -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);
|
||||
});
|
||||
Reference in New Issue
Block a user