[object Object]

← 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

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 →