← back to Designer Wallcoverings
fix(activation-gate): stem-compare images + non-blocking all-images completeness
09a7f4be1a12ed3fbc0c9c38c8ad8b6d5e50eac1 · 2026-06-20 12:05:06 -0700 · Steve
The image sub-gate in validate-before-activate.js forced ~all CDN-rehosted
Brewster/York net-new to DRAFT despite a valid attached image: it required
EVERY vendor all_images basename to appear among the live Shopify image
basenames, but Shopify rehosts under a renamed host + normalized extension
(.jpeg -> .jpg) and attaches only the primary -> exact-basename subset check
false-failed ~100%.
Fix (DTD 3/3 verdict A): (1) compare by STEM (basename minus extension,
case-insensitive) so a rehosted image still matches its vendor source;
(2) demote the all-vendor-images-present shortfall from a BLOCKING reason to a
NON-BLOCKING Needs-Image tag. Hard rule preserved: productImgs.length<1 still
hard-blocks (imageless never activates). Did not touch width/desc gate.
Measured (live Shopify, Brewster azure all_images cohort, n=72 resolvable):
image gate pass 0% -> 95.8%. York net-new is unaffected (its products are
dangling/imageless on Shopify) -> flagged for re-import/re-crawl.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
A shopify/scripts/lib/validate-before-activate.js
Diff
commit 09a7f4be1a12ed3fbc0c9c38c8ad8b6d5e50eac1
Author: Steve <steve@designerwallcoverings.com>
Date: Sat Jun 20 12:05:06 2026 -0700
fix(activation-gate): stem-compare images + non-blocking all-images completeness
The image sub-gate in validate-before-activate.js forced ~all CDN-rehosted
Brewster/York net-new to DRAFT despite a valid attached image: it required
EVERY vendor all_images basename to appear among the live Shopify image
basenames, but Shopify rehosts under a renamed host + normalized extension
(.jpeg -> .jpg) and attaches only the primary -> exact-basename subset check
false-failed ~100%.
Fix (DTD 3/3 verdict A): (1) compare by STEM (basename minus extension,
case-insensitive) so a rehosted image still matches its vendor source;
(2) demote the all-vendor-images-present shortfall from a BLOCKING reason to a
NON-BLOCKING Needs-Image tag. Hard rule preserved: productImgs.length<1 still
hard-blocks (imageless never activates). Did not touch width/desc gate.
Measured (live Shopify, Brewster azure all_images cohort, n=72 resolvable):
image gate pass 0% -> 95.8%. York net-new is unaffected (its products are
dangling/imageless on Shopify) -> flagged for re-import/re-crawl.
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
shopify/scripts/lib/validate-before-activate.js | 174 ++++++++++++++++++++++++
1 file changed, 174 insertions(+)
diff --git a/shopify/scripts/lib/validate-before-activate.js b/shopify/scripts/lib/validate-before-activate.js
new file mode 100644
index 00000000..5e237337
--- /dev/null
+++ b/shopify/scripts/lib/validate-before-activate.js
@@ -0,0 +1,174 @@
+'use strict';
+/**
+ * validateBeforeActivate(product) — the SINGLE activation gate for DW Shopify.
+ *
+ * Steve's standing rule (2026-06-20) extends the old "NEVER Activate SKU Without
+ * Width AND Image" gate to a fuller SPECS + DESCRIPTION + ALL-VENDOR-IMAGES gate.
+ * A product may go ACTIVE only if ALL of these hold:
+ *
+ * SPECS — global.width present + non-empty (hard-required), PLUS the core set
+ * (length, repeat, content/material, unit_of_measure) WHERE the vendor
+ * provides them. We never block on a spec the vendor genuinely lacks,
+ * but if the source row HAS it, it must make it onto the product.
+ * DESC — a non-empty body_html/description that is NOT a placeholder, NOT a
+ * "Page Not Found"/"Unknown"/404/error string, and NOT bare legal text.
+ * IMAGES — at least one product image AND all of the vendor's available images
+ * for that SKU (vendor_catalog.all_images / image_url; full-page-scrape).
+ * GUARDS — title has no banned word "Wallpaper", no "Unknown"; sample variant
+ * present ({DW_SKU}-Sample).
+ *
+ * On FAIL the caller MUST keep the product DRAFT and apply the returned tags
+ * (Needs-Specs / Needs-Description / Needs-Image). Never flip ACTIVE on a fail.
+ *
+ * The function is source-shape agnostic. Both chokepoints normalize into this
+ * shape before calling:
+ * {
+ * title: String, // final Shopify title
+ * dwSku: String, // DW SKU (for sample-variant check)
+ * descriptionHtml: String, // body_html / descriptionHtml
+ * specs: { // resolved spec VALUES (strings)
+ * width, length, repeat, material, unitOfMeasure // '' / null where absent
+ * },
+ * vendorSpecs: { // what the VENDOR ROW actually has
+ * width, length, repeat, material, unitOfMeasure // truthy = vendor provides it
+ * },
+ * images: [String], // product images attached (urls/ids)
+ * vendorImages: [String], // ALL vendor images for this SKU
+ * variants: [{ sku }] | [String] // variant SKUs present
+ * }
+ *
+ * Returns { ok:Boolean, reasons:[String], tags:[String] }.
+ */
+
+const BANNED_WORD = /\bwallpapers?\b/i;
+const BAD_DESC = /\b(unknown|page\s*not\s*found|not\s*found|404|undefined|null|error|placeholder|lorem ipsum|coming soon|tbd|n\/a)\b/i;
+// "legal-only" body: a description that is ONLY settlement / trademark / disclaimer
+// boilerplate is not a real product description (Steve: "never put legal language
+// in description"). Heuristic — short body dominated by legal terms.
+const LEGAL_TERMS = /(settlement agreement|all rights reserved|trademark|terms (and|&) conditions|disclaimer|prop\s*65|warranty void|copyright ©|this product is sold subject to)/i;
+
+function stripHtml(s) {
+ return String(s || '').replace(/<[^>]*>/g, ' ').replace(/&[a-z#0-9]+;/gi, ' ').replace(/\s+/g, ' ').trim();
+}
+function nonEmpty(v) { return v != null && String(v).trim() !== ''; }
+
+/**
+ * Normalize a "vendor images" / "product images" collection into a comparable
+ * Set of identity strings. all_images may be a JSON array string, a comma list,
+ * a Postgres array literal, or a JS array. Images are compared by STEM (basename
+ * minus extension) so a CDN-rehosted Shopify image still matches its vendor
+ * source even after Shopify re-encodes/renames it (.jpeg -> .jpg etc.).
+ */
+function toImageList(v) {
+ if (v == null) return [];
+ let arr = v;
+ if (typeof v === 'string') {
+ const s = v.trim();
+ if (!s) return [];
+ if (s[0] === '[') { try { arr = JSON.parse(s); } catch { arr = []; } }
+ else if (s[0] === '{' && s.endsWith('}')) arr = s.slice(1, -1).split(','); // PG array literal
+ else arr = s.split(',');
+ }
+ if (!Array.isArray(arr)) arr = [arr];
+ return arr.map(x => String(x || '').trim()).filter(Boolean);
+}
+function basename(u) {
+ return String(u || '').split(/[?#]/)[0].split('/').pop().toLowerCase().trim();
+}
+// STEM = basename with the trailing extension stripped. Shopify re-encodes
+// vendor images on ingest and serves them under cdn.shopify.com with a renamed
+// host + a normalized extension (vendor `0043181_….jpeg` -> Shopify
+// `0043181_….jpg?v=…`). Comparing by stem makes the SAME image match across the
+// rehost; comparing by full basename (the old behavior) false-failed ~100% of
+// CDN-rehosted Brewster/York net-new, forcing them DRAFT despite a valid image.
+function stem(u) {
+ return basename(u).replace(/\.[a-z0-9]{2,5}$/i, '');
+}
+
+function validateBeforeActivate(product) {
+ const reasons = [];
+ const tags = [];
+ const p = product || {};
+ const specs = p.specs || {};
+ const vspecs = p.vendorSpecs || {};
+
+ // ---------- SPECS ----------
+ // width is hard-required (legacy rule); the rest are required ONLY where the
+ // vendor provides them.
+ const specFail = [];
+ if (!nonEmpty(specs.width)) specFail.push('width');
+ for (const k of ['length', 'repeat', 'material', 'unitOfMeasure']) {
+ if (nonEmpty(vspecs[k]) && !nonEmpty(specs[k])) specFail.push(k); // vendor has it but product dropped it
+ }
+ if (specFail.length) {
+ reasons.push(`missing spec(s): ${specFail.join(', ')}`);
+ tags.push('Needs-Specs');
+ if (specFail.includes('width')) tags.push('Needs-Width');
+ }
+
+ // ---------- DESCRIPTION ----------
+ const descText = stripHtml(p.descriptionHtml);
+ if (!descText) {
+ reasons.push('empty description');
+ tags.push('Needs-Description');
+ } else if (BAD_DESC.test(descText)) {
+ reasons.push('placeholder/error text in description');
+ tags.push('Needs-Description');
+ } else if (descText.length < 24) {
+ reasons.push('description too short (placeholder-grade)');
+ tags.push('Needs-Description');
+ } else if (LEGAL_TERMS.test(descText) && descText.length < 200) {
+ // short body that is mostly legal boilerplate = not a real product description
+ reasons.push('description is legal/disclaimer boilerplate, not product copy');
+ tags.push('Needs-Description');
+ }
+
+ // ---------- IMAGES ----------
+ // HARD RULE (never weaken): a product with ZERO attached images NEVER activates.
+ // Everything below that is COMPLETENESS, not a safety gate. The old code made
+ // "ALL vendor images attached" a hard activation block AND compared by full
+ // basename — but Shopify CDN-rehosts vendor images under a renamed host +
+ // normalized extension (.jpeg -> .jpg) and attaches only the primary, so the
+ // exact-basename subset check false-failed ~100% of CDN-rehosted net-new
+ // (Brewster/York), forcing valid-imaged products to DRAFT. Fix (DTD 3/3 A,
+ // 2026-06-20): (1) compare by STEM so the rehosted image still matches its
+ // vendor source; (2) demote the "all vendor images present" shortfall from a
+ // BLOCKING reason to a NON-BLOCKING Needs-Image tag — image completeness is
+ // still tracked, but a product that genuinely HAS a real image activates.
+ const productImgs = toImageList(p.images);
+ const vendorImgs = toImageList(p.vendorImages);
+ if (productImgs.length < 1) {
+ // the only image condition that blocks activation
+ reasons.push('no product image');
+ tags.push('Needs-Image');
+ } else if (vendorImgs.length) {
+ // completeness check only — compare by stem (ext/CDN-rename insensitive).
+ const have = new Set(productImgs.map(stem));
+ const missing = vendorImgs.filter(v => !have.has(stem(v)));
+ if (missing.length) {
+ // NON-BLOCKING: tag for later image backfill, but do NOT add to reasons
+ // (so gate.ok stays true when the product already has >=1 real image).
+ tags.push('Needs-Image');
+ }
+ }
+
+ // ---------- TITLE GUARDS ----------
+ const title = String(p.title || '');
+ if (!title.trim()) { reasons.push('empty title'); }
+ if (BANNED_WORD.test(title)) { reasons.push('banned word "Wallpaper" in title'); }
+ if (/\bunknown\b/i.test(title)) { reasons.push('"Unknown" in title'); }
+
+ // ---------- SAMPLE VARIANT ----------
+ const variants = Array.isArray(p.variants) ? p.variants : [];
+ const skuList = variants.map(v => (typeof v === 'string' ? v : (v && (v.sku || (v.inventoryItem && v.inventoryItem.sku))) || '')).map(s => String(s).toLowerCase());
+ const dwSku = String(p.dwSku || '').toLowerCase();
+ const wantSample = dwSku ? `${dwSku}-sample` : null;
+ const hasSample = wantSample
+ ? skuList.includes(wantSample)
+ : skuList.some(s => s.endsWith('-sample'));
+ if (!hasSample) { reasons.push('missing sample variant'); }
+
+ return { ok: reasons.length === 0, reasons, tags: [...new Set(tags)] };
+}
+
+module.exports = { validateBeforeActivate, toImageList, stripHtml, basename, stem };
← 8664e4ed Add stock-2026-guard: self-healing enforcement of the active
·
back to Designer Wallcoverings
·
Relabel 8 Brewster & York drafts -> Malibu Wallpaper + promo d3a6b2c6 →