← 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
A scripts/preflight/preflight-validate.jsA scripts/preflight/rules.json
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 →