← back to Dw Yolo Loop

scripts/preflight/preflight-validate.js

278 lines

#!/usr/bin/env node
/**
 * preflight-validate.js — fail-closed pre-flight validator for DW product payloads.
 *
 * Codifies Steve's machine-checkable HARD rules (see rules.json + its _meta.sources)
 * and scans a products.json (flat array) OR a Shopify-queue payload shape, exiting
 * non-zero on any violation.
 *
 * USAGE:
 *   node preflight-validate.js <path-to-products.json> [--json] [--max-samples=N] [--rules=path]
 *
 * - Field-adaptive: a rule whose required fields are ALL absent is reported N/A
 *   (not PASS) so a thin payload never produces a false green.
 * - Fail-closed: any non-N/A FAIL => exit 1. Clean / all-N/A => exit 0.
 *
 * LOCAL / REPORT tool. Does NOT mutate input, does NOT write prod/Shopify.
 */
'use strict';
const fs = require('fs');
const path = require('path');

// ---------- args ----------
const args = process.argv.slice(2);
const flags = {};
const positional = [];
for (const a of args) {
  if (a.startsWith('--')) {
    const [k, v] = a.slice(2).split('=');
    flags[k] = v === undefined ? true : v;
  } else positional.push(a);
}
const inputPath = positional[0];
const asJson = !!flags.json;
const MAX_SAMPLES = parseInt(flags['max-samples'] || '8', 10);
const rulesPath = flags.rules || path.join(__dirname, 'rules.json');

if (!inputPath) {
  console.error('usage: node preflight-validate.js <products.json> [--json] [--max-samples=N] [--rules=path]');
  process.exit(2);
}

// ---------- load ----------
let RULES;
try { RULES = JSON.parse(fs.readFileSync(rulesPath, 'utf8')); }
catch (e) { console.error('FATAL: cannot read rules.json: ' + e.message); process.exit(2); }

let raw;
try { raw = JSON.parse(fs.readFileSync(inputPath, 'utf8')); }
catch (e) { console.error('FATAL: cannot read input: ' + e.message); process.exit(2); }

// Accept: array, {products:[]}, {data:[]}, or any single array-valued key.
let rows;
if (Array.isArray(raw)) rows = raw;
else if (raw && Array.isArray(raw.products)) rows = raw.products;
else if (raw && Array.isArray(raw.data)) rows = raw.data;
else if (raw && typeof raw === 'object') rows = Object.values(raw).find(v => Array.isArray(v));
if (!Array.isArray(rows)) { console.error('FATAL: could not find a product array in input'); process.exit(2); }

const HOUSE = (RULES._meta.house_brands || []).map(norm);

// ---------- helpers ----------
function norm(s) { return String(s == null ? '' : s).toLowerCase().replace(/[^a-z0-9]+/g, ''); }
function hasField(row, f) { return Object.prototype.hasOwnProperty.call(row, f) && row[f] != null && row[f] !== ''; }
function anyFieldPresent(row, fields) { return fields.some(f => Object.prototype.hasOwnProperty.call(row, f)); }
function textOf(v) {
  if (v == null) return '';
  if (Array.isArray(v)) return v.map(textOf).join(' ');
  if (typeof v === 'object') return Object.values(v).map(textOf).join(' ');
  return String(v);
}
function rowId(row) { return row.dw_sku || row.sku || row.handle || row.id || row.title || '(no-id)'; }
function num(v) { const n = parseFloat(v); return Number.isFinite(n) ? n : null; }

// Collect variants in a tolerant way (Shopify-shape).
function variantsOf(row) {
  if (Array.isArray(row.variants)) return row.variants;
  return [];
}
function isSampleVariant(v) {
  const t = norm([v && v.title, v && v.option1, v && v.option2, v && v.sku].map(x => x || '').join(' '));
  return /(sample|memo|swatch)/.test(t);
}
function isRollVariant(v) {
  const t = norm([v && v.title, v && v.option1, v && v.option2].map(x => x || '').join(' '));
  return /(roll|yard|bolt|panel|each)/.test(t) || (!isSampleVariant(v) && t.length > 0);
}

// ---------- per-rule checkers ----------
// Each returns { applicable: bool, violations: [{id, field, value, why}] }

function checkNoWordWallpaper(rule, row) {
  const fields = rule.applies_to_fields.filter(f => hasField(row, f));
  if (!fields.length) return { applicable: false, violations: [] };
  const out = [];
  for (const f of fields) {
    const t = textOf(row[f]);
    // whole-word, case-insensitive
    if (/\bwallpapers?\b/i.test(t)) out.push({ field: f, value: t.slice(0, 120), why: "contains 'wallpaper'" });
  }
  return { applicable: true, violations: out };
}

function checkNoUnknown(rule, row) {
  const fields = rule.applies_to_fields.filter(f => hasField(row, f));
  if (!fields.length) return { applicable: false, violations: [] };
  const out = [];
  for (const f of fields) {
    const t = textOf(row[f]);
    if (/\bunknown\b/i.test(t)) out.push({ field: f, value: t.slice(0, 120), why: "contains 'Unknown'" });
  }
  return { applicable: true, violations: out };
}

function check425(rule, row) {
  const SV = rule.sample_value;
  const variants = variantsOf(row);
  let applicable = false;
  const out = [];
  if (variants.length) {
    applicable = true;
    for (const v of variants) {
      const p = num(v && v.price);
      if (p === SV && !isSampleVariant(v) && isRollVariant(v)) {
        out.push({ field: 'variants', value: '$' + SV + ' on ' + (v.title || v.sku || 'variant'), why: '$4.25 on a non-sample/roll variant' });
      }
    }
  }
  // product-level price fields (flat shape): a price of exactly 4.25 read as THE price is the trap
  for (const f of ['retail_price', 'net_price', 'price']) {
    if (hasField(row, f)) {
      applicable = true;
      if (num(row[f]) === SV) out.push({ field: f, value: '$' + SV, why: 'product-level price reads $4.25 (sample-trap)' });
    }
  }
  return { applicable, violations: out };
}

function brandTokensIn(row) {
  // determine vendor / brand context for kravet-family + leak checks
  return norm([row.vendor, row.title, row.tags, row.handle, row.product_type].map(x => textOf(x)).join(' '));
}

function checkKravetMap(rule, row) {
  const ctx = brandTokensIn(row);
  const fam = (rule.kravet_family || []).map(norm);
  const isKravet = fam.some(k => ctx.indexOf(k) >= 0);
  if (!isKravet) return { applicable: false, violations: [] };
  // need a cost AND a retail to evaluate the floor
  const cost = num(row.whls_cost) ?? num(row.cost) ?? num(row.cost_price) ?? num(row.net_price);
  let retail = num(row.retail_price) ?? num(row.price) ?? num(row.map_price);
  if (retail == null) {
    const variants = variantsOf(row);
    const rollPrices = variants.filter(v => !isSampleVariant(v)).map(v => num(v.price)).filter(x => x != null);
    if (rollPrices.length) retail = Math.max(...rollPrices);
  }
  if (cost == null || retail == null) {
    // kravet-family but we lack the numbers to judge -> applicable but cannot fail; report as N/A-data
    return { applicable: true, violations: [], note: 'kravet-family but missing cost and/or retail to evaluate MAP floor' };
  }
  const floor = cost * (rule.map_multiplier || 1.5);
  const out = [];
  if (retail + 1e-6 < floor) {
    out.push({ field: 'retail_price', value: 'retail $' + retail + ' < MAP floor $' + floor.toFixed(2) + ' (cost $' + cost + ' x ' + rule.map_multiplier + ')', why: 'below Kravet MAP floor' });
  }
  return { applicable: true, violations: out };
}

function checkActiveCompleteness(rule, row) {
  // only meaningful if we know status
  const status = (row.status != null) ? String(row.status).toLowerCase() : null;
  if (status == null) return { applicable: false, violations: [] };
  if (status !== 'active') return { applicable: true, violations: [] };
  const out = [];
  // image
  const hasImage = hasField(row, 'image_url') || (Array.isArray(row.images) && row.images.length > 0);
  if (!hasImage) out.push({ field: 'image', value: '(none)', why: 'ACTIVE without image' });
  // width metafield
  let hasWidth = hasField(row, 'width');
  if (!hasWidth && Array.isArray(row.metafields)) {
    hasWidth = row.metafields.some(m => m && /width/i.test((m.key || '') + ' ' + (m.namespace || '')));
  }
  if (!hasWidth) out.push({ field: 'width', value: '(none)', why: 'ACTIVE without width metafield' });
  // inventory=2026 on BOTH variants
  const variants = variantsOf(row);
  if (variants.length) {
    const bad = variants.filter(v => String((v && (v.inventory != null ? v.inventory : v.inventory_year)) || row.inventory || '') !== rule.required_inventory_year);
    if (bad.length) out.push({ field: 'inventory', value: bad.length + '/' + variants.length + ' variant(s) != ' + rule.required_inventory_year, why: 'ACTIVE without inventory=2026 on both variants' });
  } else if (hasField(row, 'inventory') && String(row.inventory) !== rule.required_inventory_year) {
    out.push({ field: 'inventory', value: String(row.inventory), why: 'ACTIVE without inventory=2026' });
  }
  return { applicable: true, violations: out };
}

function checkVendorLeak(rule, row) {
  const fields = rule.applies_to_fields.filter(f => hasField(row, f));
  if (!fields.length) return { applicable: false, violations: [] };
  const deny = (rule.denylist || []).map(norm);
  const out = [];
  for (const f of fields) {
    const t = textOf(row[f]);
    const n = norm(t);
    if (!n) continue;
    for (let i = 0; i < deny.length; i++) {
      if (n.indexOf(deny[i]) >= 0) {
        // allow if the ONLY brand context is a house brand AND the denylisted token is not separately present
        // (house brands are never on the denylist, so any denylist hit is a real leak)
        out.push({ field: f, value: t.slice(0, 120), why: "denylisted vendor token '" + rule.denylist[i] + "'" });
        break; // one hit per field is enough
      }
    }
  }
  return { applicable: true, violations: out };
}

const CHECKERS = {
  no_word_wallpaper: checkNoWordWallpaper,
  no_unknown_token: checkNoUnknown,
  no_425_on_roll_variant: check425,
  kravet_map_floor: checkKravetMap,
  active_completeness: checkActiveCompleteness,
  no_denylisted_vendor_token: checkVendorLeak
};

// ---------- run ----------
const report = [];
for (const rule of RULES.rules) {
  const fn = CHECKERS[rule.check];
  if (!fn) { report.push({ id: rule.id, status: 'SKIP', reason: 'no checker for ' + rule.check }); continue; }
  let applicableRows = 0, failCount = 0, dataGapRows = 0;
  const samples = [];
  for (const row of rows) {
    const r = fn(rule, row);
    if (!r.applicable) continue;
    applicableRows++;
    if (r.note) dataGapRows++;
    if (r.violations && r.violations.length) {
      failCount++;
      if (samples.length < MAX_SAMPLES) {
        samples.push({ id: rowId(row), hits: r.violations });
      }
    }
  }
  let status;
  if (applicableRows === 0) status = 'N/A';
  else if (failCount === 0) status = 'PASS';
  else status = 'FAIL';
  report.push({ id: rule.id, title: rule.title, status, applicableRows, failCount, dataGapRows, samples });
}

// ---------- output ----------
const anyFail = report.some(r => r.status === 'FAIL');
if (asJson) {
  console.log(JSON.stringify({ input: inputPath, totalRows: rows.length, report, anyFail }, null, 2));
} else {
  console.log('DW PRE-FLIGHT VALIDATOR');
  console.log('input: ' + inputPath + '   rows: ' + rows.length + '   rules: ' + RULES.rules.length);
  console.log('='.repeat(72));
  for (const r of report) {
    if (r.status === 'SKIP') { console.log('[SKIP] ' + r.id + ' — ' + r.reason); continue; }
    const tag = r.status === 'FAIL' ? 'FAIL' : r.status === 'PASS' ? 'PASS' : r.status === 'N/A' ? 'N/A ' : r.status;
    let line = '[' + tag + '] ' + r.id;
    if (r.status === 'FAIL') line += '  — ' + r.failCount + '/' + r.applicableRows + ' applicable rows violate';
    else if (r.status === 'PASS') line += '  — ' + r.applicableRows + ' applicable rows clean';
    else if (r.status === 'N/A') line += '  — no rows carry the required field(s)';
    console.log(line);
    console.log('       ' + (r.title || ''));
    if (r.dataGapRows) console.log('       (' + r.dataGapRows + ' applicable rows lacked cost/price data to fully evaluate)');
    for (const s of r.samples) {
      const h = s.hits.map(x => x.field + ': ' + x.why).join('; ');
      console.log('         · ' + s.id + ' → ' + h);
    }
  }
  console.log('='.repeat(72));
  console.log(anyFail ? 'RESULT: VIOLATIONS FOUND (exit 1)' : 'RESULT: clean / no applicable violations (exit 0)');
}

process.exit(anyFail ? 1 : 0);