← back to Wallco Ai

scripts/audit-hard-rules.js

97 lines

#!/usr/bin/env node
/**
 * audit-hard-rules.js — one command that verifies the data-integrity hard rules
 * from CLAUDE.md still hold against the live catalog + shipped snapshot.
 *
 * These are the rules where a silent regression is customer-facing or a privacy
 * leak, and where nothing else catches the drift after the fact:
 *
 *   1. Mural categories are NEVER tileable        (delegates to audit-mural-kinds.js)
 *      — a tile kind in a mural category renders as a broken tiny repeating tile.
 *   2. data/designs.json MUST NOT leak local_path  (or any /Users/<home> path)
 *      — that column leaks absolute home paths through the PUBLIC /api/designs.
 *   3. No vendor names in public UI                (the legal premise of wallco.ai)
 *      — a denylisted vendor name in any customer-visible field is an exposure.
 *   4. No "FLIEPAPER" word in any customer-facing surface.
 *
 * Read-only. Never writes the DB or any file. Exit 0 = all clear, 1 = a rule is
 * violated (so it is usable as a pre-deploy gate / cron canary).
 *
 * Usage:  node scripts/audit-hard-rules.js
 */
'use strict';
const path = require('path');
const fs = require('fs');
const { spawnSync } = require('child_process');

let failed = false;
const section = (n) => console.log(`\n### ${n}`);

// --- Rule 1: mural categories never tileable (reuse the canonical checker) ---
section('1. mural categories are never tileable');
{
  const r = spawnSync('node', [path.join(__dirname, 'audit-mural-kinds.js')], { encoding: 'utf8' });
  process.stdout.write(r.stdout || '');
  if (r.stderr) process.stderr.write(r.stderr);
  if (r.status !== 0) { failed = true; console.log('  -> FAIL'); }
  else console.log('  -> PASS');
}

// --- Rules 2-4: scan the public snapshot once, run all field-level checks ---
// Customer-visible string fields that reach the public PDP / grid / /api/designs.
const PUBLIC_FIELDS = ['title', 'category', 'handle', 'ai_title', 'name', 'ai_pattern', 'ai_color_name', 'colorway', 'pattern_name'];
// Vendor denylist — KEEP IN SYNC with server.js (the VENDOR_DENYLIST regex, ~line 875).
const VENDOR_DENYLIST = /\b(thibaut|koroseal|schumacher|maya[ -]romanoff|scalamandre|arte[ -]international|cole[ -]?(and|&)[ -]?son|phillip[ -]jeffries|osborne[ -]?(and|&)?[ -]?little|de[ -]gournay|fromental|sister[ -]parish|dedar|brewster|mind[ -]the[ -]gap|hygge|designtex|wolf[ -]gordon|carnegie|coordonn|daisy[ -]bennett|china[ -]seas|york|marburg|romo|fabricut|stout|knoll|arteriors|timorous[ -]beasties|hangups)\b/i;
const FLIEPAPER = /fliepaper/i;

const jsonPath = path.join(__dirname, '..', 'data', 'designs.json');
if (!fs.existsSync(jsonPath)) {
  section('2-4. public snapshot checks');
  console.log('  data/designs.json absent — skipped (nothing to leak)');
} else {
  const parsed = JSON.parse(fs.readFileSync(jsonPath, 'utf8'));
  const rows = Array.isArray(parsed) ? parsed : (parsed.designs || parsed.items || []);
  const withKey = [];   // rule 2: local_path key
  const withHome = [];  // rule 2: /Users home-path leak
  const vendorHits = [];// rule 3
  const flieHits = [];  // rule 4
  for (const row of rows) {
    if (!row || typeof row !== 'object') continue;
    if ('local_path' in row) withKey.push(row.id);
    for (const [k, v] of Object.entries(row)) {
      if (typeof v !== 'string') continue;
      if (v.includes('/Users/stevestudio2')) withHome.push([row.id, k]);
    }
    for (const f of PUBLIC_FIELDS) {
      const v = row[f];
      if (typeof v !== 'string') continue;
      const m = VENDOR_DENYLIST.exec(v);
      if (m) vendorHits.push([row.id, f, m[0], v.slice(0, 50)]);
      if (FLIEPAPER.test(v)) flieHits.push([row.id, f, v.slice(0, 50)]);
    }
  }

  section('2. data/designs.json carries no local_path / home-path leak');
  console.log(`  rows=${rows.length}  local_path-key=${withKey.length}  home-path-leak=${withHome.length}`);
  if (withKey.length || withHome.length) {
    failed = true;
    console.log(`  -> FAIL  local_path ids: ${withKey.slice(0, 10).join(', ')}  home-leak: ${JSON.stringify(withHome.slice(0, 10))}`);
  } else console.log('  -> PASS');

  section('3. no vendor names in public-facing snapshot fields');
  console.log(`  scanned ${PUBLIC_FIELDS.length} public fields x ${rows.length} rows`);
  if (vendorHits.length) {
    failed = true;
    console.log(`  -> FAIL  ${vendorHits.length} hit(s): ${JSON.stringify(vendorHits.slice(0, 10))}`);
  } else console.log('  -> PASS');

  section('4. no FLIEPAPER word in public-facing snapshot fields');
  if (flieHits.length) {
    failed = true;
    console.log(`  -> FAIL  ${flieHits.length} hit(s): ${JSON.stringify(flieHits.slice(0, 10))}`);
  } else console.log('  -> PASS');
}

console.log(`\n=== HARD-RULE AUDIT: ${failed ? 'FAIL — a rule is violated (see above)' : 'ALL CLEAR'} ===`);
process.exit(failed ? 1 : 0);