← 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);