← back to Wallco Ai

scripts/audit-mural-kinds.js

87 lines

#!/usr/bin/env node
/**
 * audit-mural-kinds.js — standing audit for the hard rule
 *   "Mural categories are NEVER tileable" (CLAUDE.md).
 *
 * The insert-time guard `assertMuralKind()` in lib/mural-categories.js only
 * protects rows that go THROUGH it. The 2026-05-27 incident was 12 rows that
 * BYPASSED it via direct SQL (inserted as kind='seamless_tile' into
 * monterey-mural / cactus-pine-scenic) — each rendered as a tiny repeating tile
 * on the /designs grid + PDP and looked broken. Nothing catches that class of
 * drift after the fact. This script does: a read-only sweep that any session or
 * cron can run to confirm the rule still holds.
 *
 * A mural-category row with a TILE kind (seamless_tile / tile / repeat_tile) is
 * a VIOLATION. Published violations are customer-facing-broken (exit 1);
 * unpublished ones are latent (warned, exit 0 unless --strict).
 *
 * Canonical category + tile-kind lists come straight from lib/mural-categories.js
 * (single source of truth — no drift). DB read goes through lib/db.js psqlQuery
 * (local psql, same config as server.js).
 *
 * Usage:
 *   node scripts/audit-mural-kinds.js            # report; exit 1 on LIVE violations
 *   node scripts/audit-mural-kinds.js --strict   # exit 1 on ANY violation (incl. unpublished)
 *   node scripts/audit-mural-kinds.js --fix-sql  # print (do NOT run) the corrective UPDATE
 */
'use strict';
const { MURAL_CATEGORIES, TILE_KINDS, muralKindFor } = require('../lib/mural-categories.js');
const { psqlQuery } = require('../lib/db.js');

const STRICT = process.argv.includes('--strict');
const FIX_SQL = process.argv.includes('--fix-sql');

// Build an inline SQL list from the canonical sets so the query can never drift
// from lib/mural-categories.js. Values are static identifiers (no user input),
// but escape single-quotes defensively anyway.
const sqlList = (set) => [...set].map((s) => `'${String(s).replace(/'/g, "''")}'`).join(', ');
const MURAL_SQL = sqlList(MURAL_CATEGORIES);
const TILE_SQL = sqlList(TILE_KINDS);

function rows(sql) {
  const out = psqlQuery(sql);
  if (!out) return [];
  return out.split('\n').filter(Boolean).map((line) => line.split('|'));
}

function main() {
  // Violations: a mural-category row carrying a tile kind.
  const bad = rows(
    `SELECT id, lower(trim(category)), kind, is_published
       FROM all_designs
      WHERE lower(trim(category)) IN (${MURAL_SQL})
        AND kind IN (${TILE_SQL})
      ORDER BY is_published DESC, id`
  );

  const live = bad.filter((r) => r[3] === 't');
  const latent = bad.filter((r) => r[3] !== 't');

  console.log('=== mural-kind audit (hard rule: mural categories are never tileable) ===');
  console.log(`categories checked: ${[...MURAL_CATEGORIES].join(', ')}`);
  console.log(`tile kinds refused: ${[...TILE_KINDS].join(', ')}`);
  console.log('');

  if (!bad.length) {
    console.log('RESULT: CLEAN — every mural-category row is mural_panel/mural. Rule holds.');
  } else {
    console.log(`RESULT: ${bad.length} VIOLATION(S) — ${live.length} LIVE (customer-facing-broken), ${latent.length} unpublished (latent):`);
    for (const r of bad) {
      console.log(`  #${r[0]}  ${r[1]}  kind=${r[2]}  ${r[3] === 't' ? 'LIVE' : 'unpublished'}`);
    }
    if (FIX_SQL) {
      console.log('\n-- corrective SQL (review, then run by hand — this script never writes):');
      for (const r of bad) {
        console.log(`UPDATE all_designs SET kind='${muralKindFor(r[1])}' WHERE id=${r[0]};`);
      }
    } else {
      console.log('\nrun with --fix-sql to print the corrective UPDATEs (script never writes the DB itself).');
    }
  }

  const failed = live.length > 0 || (STRICT && bad.length > 0);
  process.exit(failed ? 1 : 0);
}

main();