[object Object]

← back to Dw Yolo Loop

Add fail-closed pre-flight validator (rules.json + preflight-validate.js) codifying MEMORY/CLAUDE.md hard rules

d086ddd874809576b6da431de8655ae48c2eaeb4 · 2026-06-14 21:15:41 -0700 · Steve Abrams

Files touched

Diff

commit d086ddd874809576b6da431de8655ae48c2eaeb4
Author: Steve Abrams <steve@designerwallcoverings.com>
Date:   Sun Jun 14 21:15:41 2026 -0700

    Add fail-closed pre-flight validator (rules.json + preflight-validate.js) codifying MEMORY/CLAUDE.md hard rules
---
 scripts/preflight/preflight-validate.js | 277 ++++++++++++++++++++++++++++++++
 scripts/preflight/rules.json            |  90 +++++++++++
 2 files changed, 367 insertions(+)

diff --git a/scripts/preflight/preflight-validate.js b/scripts/preflight/preflight-validate.js
new file mode 100644
index 0000000..a7afd61
--- /dev/null
+++ b/scripts/preflight/preflight-validate.js
@@ -0,0 +1,277 @@
+#!/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);
diff --git a/scripts/preflight/rules.json b/scripts/preflight/rules.json
new file mode 100644
index 0000000..d4f758f
--- /dev/null
+++ b/scripts/preflight/rules.json
@@ -0,0 +1,90 @@
+{
+  "_meta": {
+    "name": "DW pre-flight validator ruleset",
+    "version": "1.0.0",
+    "created": "2026-06-14",
+    "sources": [
+      "~/.claude/CLAUDE.md (global standing rules)",
+      "~/.claude/projects/-Users-stevestudio2-Projects-designerwallcoverings/memory/MEMORY.md",
+      "~/Projects/_shared/sku-redact.js (canonical VENDORS denylist)"
+    ],
+    "note": "Machine-checkable HARD rules only. Each rule names the field(s) it reads; a rule is reported N/A (not PASS) when none of its required fields are present in the payload. Fail-closed: any non-N/A FAIL => validator exits non-zero.",
+    "house_brands": [
+      "phillipe romano", "philippe romano", "hollywood", "artmura", "saybrook house",
+      "designer wallcoverings", "architectural wallcoverings", "novasuede"
+    ]
+  },
+  "rules": [
+    {
+      "id": "wallpaper-banned",
+      "severity": "error",
+      "title": "Word 'Wallpaper' banned in DW catalog text (use 'Wallcovering')",
+      "rationale": "CLAUDE.md / MEMORY: DW catalog titles/copy/tags must say 'Wallcovering', never 'Wallpaper'.",
+      "applies_to_fields": ["title", "body_html", "description", "tags", "product_type"],
+      "check": "no_word_wallpaper",
+      "_note": "Whole-word, case-insensitive. Skips well-known proper-noun exceptions (e.g. vendor brand 'Madagascar Wallpaper') only when matched inside a denylisted vendor token already flagged elsewhere; here we flag plain 'wallpaper' occurrences."
+    },
+    {
+      "id": "no-unknown-in-title",
+      "severity": "error",
+      "title": "Never 'Unknown' in a Shopify title",
+      "rationale": "MEMORY/standing rule: a title containing 'Unknown' is a data defect.",
+      "applies_to_fields": ["title"],
+      "check": "no_unknown_token"
+    },
+    {
+      "id": "sample-trap-425",
+      "severity": "error",
+      "title": "$4.25 must never be a ROLL / non-sample variant price (sample-trap)",
+      "rationale": "MEMORY 425-sample-price-policy: $4.25 is the memo-SAMPLE variant ONLY. A roll/non-sample variant priced at 4.25 (or a product-level price read of 4.25) is the $4.25 garbage bug.",
+      "applies_to_fields": ["variants", "retail_price", "net_price", "price"],
+      "check": "no_425_on_roll_variant",
+      "sample_value": 4.25
+    },
+    {
+      "id": "kravet-map-floor",
+      "severity": "error",
+      "title": "Kravet-family price must be >= wholesale x 1.5 (MAP floor)",
+      "rationale": "CLAUDE.md kravet-lines-price-at-map: every Kravet-umbrella brand sells at MAP = WHLS cost x 1.5. A retail below that floor is off-MAP and non-compliant.",
+      "applies_to_fields": ["vendor", "title", "tags", "cost", "net_price", "whls_cost", "retail_price", "price", "variants"],
+      "check": "kravet_map_floor",
+      "map_multiplier": 1.5,
+      "kravet_family": [
+        "kravet", "lee jofa", "lee jofa modern", "groundworks", "brunschwig",
+        "cole and son", "cole & son", "gp j baker", "gp & j baker", "colefax",
+        "clarke and clarke", "clarke & clarke", "mulberry", "threads",
+        "baker lifestyle", "andrew martin", "nicolette mayer", "aerin",
+        "barclay butera", "thom filicia", "kravet couture", "kravet design",
+        "kravet contract", "kravet basics"
+      ]
+    },
+    {
+      "id": "active-needs-image-width-inventory",
+      "severity": "error",
+      "title": "ACTIVE products need image + width metafield + inventory=2026 on BOTH variants",
+      "rationale": "CLAUDE.md/MEMORY: never ACTIVE without image + width metafield; activating any new item sets inventory=2026 on BOTH variants.",
+      "applies_to_fields": ["status", "image_url", "images", "metafields", "width", "variants", "inventory"],
+      "check": "active_completeness",
+      "required_inventory_year": "2026"
+    },
+    {
+      "id": "no-vendor-name-leak",
+      "severity": "error",
+      "title": "No residual 3rd-party vendor-name leaks (per sku-redact VENDORS denylist)",
+      "rationale": "CLAUDE.md/MEMORY: DW vendor names NEVER in customer-facing UI (title/handle/sku/tags/vendor). House brands are allowed. Denylist mirrors _shared/sku-redact.js VENDORS.",
+      "applies_to_fields": ["title", "handle", "dw_sku", "sku", "tags", "vendor"],
+      "check": "no_denylisted_vendor_token",
+      "denylist": [
+        "wolf gordon", "nina campbell", "jeffrey stevens", "versace",
+        "arte international", "lee jofa modern", "lee jofa", "innovations usa",
+        "innovations", "koroseal", "schumacher", "candice olson", "thibaut",
+        "maya romanoff", "scalamandre", "dedar", "carnegie", "cole and son",
+        "kravet", "clarke and clarke", "brunschwig", "gp j baker", "sandberg",
+        "designers guild", "graham and brown", "harlequin", "andrew martin",
+        "ronald redding", "blithfield", "coordonne", "fentucci", "sister parish",
+        "phillip jeffries", "fromental", "westport", "breegan", "les ensembliers",
+        "roger thomas", "stacy garcia"
+      ]
+    }
+  ]
+}

← 148daae brands-page: drop dead 1838 tile, repoint Surface Stick to a  ·  back to Dw Yolo Loop  ·  Add read-only Active/Draft gate-integrity audit script (ACTI 20610f5 →