← back to Designer Wallcoverings
TK-11786 #1/#2: validate-before-activate shim + debug-clone abort guards + guard-refactor proof
5c8fd6b682ad7c343c941a28bcc31b2544427e92 · 2026-09-22 13:35:25 -0700 · Steve
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ChReG53fwpNgUESv4SY7
Files touched
M shopify/scripts/activate-null-level-to-2026.jsM shopify/scripts/active-2026-and-all-channels.jsM shopify/scripts/inventory-set-2026-newest.mjsM shopify/scripts/inventory-set-2026.jsM shopify/scripts/lib/validate-before-activate.jsM shopify/scripts/set-active-zero-to-2026.jsM shopify/scripts/sweep-all-active.mjsM shopify/scripts/tk11357-source-fix-proof/predicate-proof.mjs
Diff
commit 5c8fd6b682ad7c343c941a28bcc31b2544427e92
Author: Steve <steve@designerwallcoverings.com>
Date: Tue Sep 22 13:35:25 2026 -0700
TK-11786 #1/#2: validate-before-activate shim + debug-clone abort guards + guard-refactor proof
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01G3ChReG53fwpNgUESv4SY7
---
shopify/scripts/activate-null-level-to-2026.js | 1 +
shopify/scripts/active-2026-and-all-channels.js | 1 +
shopify/scripts/inventory-set-2026-newest.mjs | 15 +-
shopify/scripts/inventory-set-2026.js | 1 +
shopify/scripts/lib/validate-before-activate.js | 207 ++-------------------
shopify/scripts/set-active-zero-to-2026.js | 1 +
shopify/scripts/sweep-all-active.mjs | 6 +-
.../tk11357-source-fix-proof/predicate-proof.mjs | 7 +-
8 files changed, 41 insertions(+), 198 deletions(-)
diff --git a/shopify/scripts/activate-null-level-to-2026.js b/shopify/scripts/activate-null-level-to-2026.js
index e578e412..635452f4 100644
--- a/shopify/scripts/activate-null-level-to-2026.js
+++ b/shopify/scripts/activate-null-level-to-2026.js
@@ -1,4 +1,5 @@
#!/usr/bin/env node
+if(!process.env.ALLOW_DEBUG_CLONE_WRITE){console.error("ABORT: TK-11357 debug clone is not a write surface. Use the guarded main repo.");process.exit(3);}
/**
* For candidates that were tracked-but-not-stocked at the location
* (ITEM_NOT_STOCKED_AT_LOCATION), inventoryActivate them at the location with
diff --git a/shopify/scripts/active-2026-and-all-channels.js b/shopify/scripts/active-2026-and-all-channels.js
index 3250a5a7..687b10a5 100644
--- a/shopify/scripts/active-2026-and-all-channels.js
+++ b/shopify/scripts/active-2026-and-all-channels.js
@@ -1,4 +1,5 @@
#!/usr/bin/env node
+if(!process.env.ALLOW_DEBUG_CLONE_WRITE){console.error("ABORT: TK-11357 debug clone is not a write surface. Use the guarded main repo.");process.exit(3);}
/**
* Store-wide enforcement sweep (Steve 2026-06-11):
* "every active item must have ALL 13 sales channels AND 2026 inventory for every variant."
diff --git a/shopify/scripts/inventory-set-2026-newest.mjs b/shopify/scripts/inventory-set-2026-newest.mjs
index f2036001..e8d8a167 100644
--- a/shopify/scripts/inventory-set-2026-newest.mjs
+++ b/shopify/scripts/inventory-set-2026-newest.mjs
@@ -1,4 +1,5 @@
#!/usr/bin/env node
+if(!process.env.ALLOW_DEBUG_CLONE_WRITE){console.error("ABORT: TK-11357 debug clone is not a write surface. Use the guarded main repo.");process.exit(3);}
/**
* inventory-set-2026-newest.js
*
@@ -20,6 +21,12 @@
import fs from 'fs';
import path from 'path';
import { fileURLToPath } from 'url';
+// TK-11857: dynamic import so the local guard lib loads AFTER the abort guard above — a static
+// import is resolved during the ESM load phase (before any top-level code runs), so if this file
+// were ever missing/broken you'd get ERR_MODULE_NOT_FOUND instead of the clean debug-clone abort.
+// A dynamic import() defers the load to runtime, past the process.exit(3). (Routes the
+// $0/NaN/price-suppressed guard through the shared chokepoint; was a weaker inline `price===0`.)
+const { safeStampQuantity } = await import('./lib/inventory-stamp-guard.mjs');
const __dir = path.dirname(fileURLToPath(import.meta.url));
const ENV_PATH = path.resolve(__dir, '../../.env'); // repo root .env
@@ -63,7 +70,7 @@ async function gql(q, v) {
}
// ---- scan newest N ----
-const SCAN = `query($c:String){products(first:100,after:$c,sortKey:CREATED_AT,reverse:true){pageInfo{hasNextPage endCursor} nodes{id title createdAt variants(first:10){nodes{sku title price inventoryItem{id tracked inventoryLevel(locationId:"${LOC}"){quantities(names:["on_hand"]){name quantity}}}}}}}}`;
+const SCAN = `query($c:String){products(first:100,after:$c,sortKey:CREATED_AT,reverse:true){pageInfo{hasNextPage endCursor} nodes{id title createdAt tags vendor variants(first:10){nodes{sku title price inventoryItem{id tracked inventoryLevel(locationId:"${LOC}"){quantities(names:["on_hand"]){name quantity}}}}}}}}`;
async function scan() {
let cur = null, prods = [];
@@ -78,7 +85,11 @@ async function scan() {
let totV = 0, correct = 0; const fixes = [];
for (const p of prods) for (const v of p.variants.nodes) {
totV++;
- if (Number(v.price) === 0) continue; // GUARD TK-10965: never re-inflate $0 quote-only variants
+ // GUARD TK-10965 / TK-11857 (DTD 2026-09-17): route through the shared safeStampQuantity
+ // chokepoint instead of a bare inline `price===0` check. This ALSO catches NaN prices
+ // (Number('') === 0 is false, so `=== 0` let them through) and price-suppressed lines
+ // (quote-only tag / Fentucci vendor) at a nonzero price. desired===0 => never stock it.
+ if (safeStampQuantity({ title: v.title, price: v.price }, p, QTY) === 0) continue;
const ii = v.inventoryItem;
const tracked = ii?.tracked, lvl = ii?.inventoryLevel;
const onh = lvl?.quantities?.find(x => x.name === 'on_hand')?.quantity;
diff --git a/shopify/scripts/inventory-set-2026.js b/shopify/scripts/inventory-set-2026.js
index 758386b4..550e814b 100644
--- a/shopify/scripts/inventory-set-2026.js
+++ b/shopify/scripts/inventory-set-2026.js
@@ -1,4 +1,5 @@
#!/usr/bin/env node
+if(!process.env.ALLOW_DEBUG_CLONE_WRITE){console.error("ABORT: TK-11357 debug clone is not a write surface. Use the guarded main repo.");process.exit(3);}
// Set On hand inventory to 2026 for all SKUs in the provided CSV at
// location "15442 Ventura Blvd." (gid://shopify/Location/5795643504).
//
diff --git a/shopify/scripts/lib/validate-before-activate.js b/shopify/scripts/lib/validate-before-activate.js
index 68fbd225..8effb3eb 100644
--- a/shopify/scripts/lib/validate-before-activate.js
+++ b/shopify/scripts/lib/validate-before-activate.js
@@ -1,198 +1,17 @@
'use strict';
/**
- * validateBeforeActivate(product) — the SINGLE activation gate for DW Shopify.
+ * validate-before-activate.js — RE-EXPORT SHIM (2026-09-22, Option-B decouple).
*
- * 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
- * vendor: String, // product vendor (for INTERNAL guard)
- * tags: [String] | String, // product tags (for INTERNAL guard)
- * 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] }.
- */
-
-// INTERNAL front-facing hard-block (Steve 2026-07-09). A product whose vendor is in
-// config/internal-lines.json OR that carries the `internal` tag is DELIBERATELY never
-// front-facing — it must NEVER go ACTIVE/published (storefront) or into the Google feed.
-// This is the single enforced gate wired into BOTH activation chokepoints, so refusing
-// here refuses every activate/publish path. Fails SAFE: if the registry can't be read,
-// internal-guard still blocks the four known luxury lines by name.
-const { isInternal } = require('./internal-guard.js');
-
-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.).
+ * The canonical master now lives at ~/Projects/_shared/dw-guards/validate-before-activate.js
+ * so both DW repos (and the 3 lowercase go-live scripts that import this file by absolute
+ * path) share ONE source of truth. This shim re-exports it verbatim, so EVERY existing
+ * importer keeps working unchanged and the hourly cadence activation gate is undisturbed.
+ * Original preserved as validate-before-activate.js.pre-shared-lift.
*/
-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 || {};
-
- // ---------- INTERNAL HARD-BLOCK (front-facing refusal) ----------
- // Checked FIRST and fails the gate outright: an internal line can never be a
- // candidate to activate/publish, regardless of specs/images/description. Keeps
- // the product DRAFT (the caller keeps DRAFT on !ok) with an `internal` tag flag.
- if (isInternal(p.vendor, p.tags)) {
- return { ok: false, reasons: ['internal line — never front-facing (config/internal-lines.json)'], tags: ['internal'] };
- }
-
- // ---------- 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.replace(/\bwithout\s+prop\s*65\s+phthalates\b/gi, '')) && descText.length < 200) {
- // Designtex's "without Prop 65 Phthalates" is a material specification,
- // not a disclaimer. Remove only that phrase for this check; any actual
- // warning/disclaimer elsewhere in the description still blocks.
- // 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());
- // A product "has a sample" if ANY variant SKU ends in -sample, OR (when the
- // variant objects carry a title) a variant is titled "Sample". The standing
- // rule wants a sample variant present — it does NOT require the sample SKU base
- // to equal the DW SKU. The old exact `${dwSku}-Sample` match falsely flagged
- // every product whose sample is keyed to the mfr_sku (e.g. SWAN-104-sample),
- // inflating residue and wrongly blocking legit products at the chokepoint.
- const titledSample = variants.some(v => v && typeof v === 'object' && /^\s*sample\s*$/i.test(v.title || ''));
- const hasSample = titledSample || 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 };
+const os = require('os');
+const path = require('path');
+module.exports = require(
+ process.env.DW_SHARED_GUARDS
+ ? path.join(process.env.DW_SHARED_GUARDS, 'validate-before-activate.js')
+ : path.join(os.homedir(), 'Projects', '_shared', 'dw-guards', 'validate-before-activate.js')
+);
diff --git a/shopify/scripts/set-active-zero-to-2026.js b/shopify/scripts/set-active-zero-to-2026.js
index 067c9d57..5a272786 100644
--- a/shopify/scripts/set-active-zero-to-2026.js
+++ b/shopify/scripts/set-active-zero-to-2026.js
@@ -1,4 +1,5 @@
#!/usr/bin/env node
+if(!process.env.ALLOW_DEBUG_CLONE_WRITE){console.error("ABORT: TK-11357 debug clone is not a write surface. Use the guarded main repo.");process.exit(3);}
/**
* Set AVAILABLE inventory = 2026 for every candidate in the latest
* active-zero-inventory scan (active products, newest→oldest, currently 0 /
diff --git a/shopify/scripts/sweep-all-active.mjs b/shopify/scripts/sweep-all-active.mjs
index f53cdd42..3469f7bf 100644
--- a/shopify/scripts/sweep-all-active.mjs
+++ b/shopify/scripts/sweep-all-active.mjs
@@ -1,4 +1,5 @@
#!/usr/bin/env node
+if(!process.env.ALLOW_DEBUG_CLONE_WRITE){console.error("ABORT: TK-11357 debug clone is not a write surface. Use the guarded main repo.");process.exit(3);}
/**
* sweep-all-active.mjs
*
@@ -31,7 +32,10 @@ async function gql(q,v){for(let a=0;a<8;a++){const r=await fetch(`https://${STOR
// 1) scan ALL active products → variant inventoryItem id + tracked
const SCAN=`query($c:String){products(first:40,after:$c,query:"status:active"){pageInfo{hasNextPage endCursor} nodes{vendor tags variants(first:15){nodes{sku title price inventoryItem{id tracked}}}}}}`;
-import { safeStampQuantity } from './lib/inventory-stamp-guard.mjs'; // GUARD TK-11357 (shared guard)
+// TK-11857: dynamic import so the local guard lib loads AFTER the abort guard at the top — a
+// static import resolves during the ESM load phase (before any top-level code runs), so a
+// missing/broken lib would throw ERR_MODULE_NOT_FOUND instead of the clean debug-clone abort.
+const { safeStampQuantity } = await import('./lib/inventory-stamp-guard.mjs'); // GUARD TK-11357 (shared guard)
// ── GUARD TK-11357 BEGIN ─ do not edit without re-running the fixture proof ──────────
// A $0 / quote-only sellable variant must NEVER receive positive stock: positive stock is what
// flips availableForSale=true, making it checkout-orderable at $0 (lineage TK-10825 -> 10965 ->
diff --git a/shopify/scripts/tk11357-source-fix-proof/predicate-proof.mjs b/shopify/scripts/tk11357-source-fix-proof/predicate-proof.mjs
index 86523288..52dada97 100644
--- a/shopify/scripts/tk11357-source-fix-proof/predicate-proof.mjs
+++ b/shopify/scripts/tk11357-source-fix-proof/predicate-proof.mjs
@@ -43,6 +43,10 @@ const GUARD = {
[RW]: `${RW}/scripts/lib/inventory-stamp-guard.mjs`,
[SP]: `${SP}/scripts/lib/inventory-stamp-guard.mjs`,
};
+// 2026-09-22 Option-B decouple: the 5 repo copies above are now RE-EXPORT SHIMS ->
+// this single canonical master. driftCheck() includes MASTER_GUARD in its comparison,
+// so it proves each shim's RESOLVED behaviour matches the master (not just each other).
+const MASTER_GUARD = `${HOME}/Projects/_shared/dw-guards/inventory-stamp-guard.mjs`;
const TARGETS = [
[DWC, 'scripts/maharam-onboard/go-live.mjs'],
[DWC, 'scripts/artmura-onboard/go-live-artmura.js'],
@@ -166,7 +170,8 @@ function compile(block, guardPath) {
// ── 0) guard-copy drift check ───────────────────────────────────────────────────────────
function driftCheck() {
- const mods = Object.values(GUARD).map(p => ({ p, m: require(p) }));
+ const mods = [{ p: MASTER_GUARD + ' (MASTER)', m: require(MASTER_GUARD) },
+ ...Object.values(GUARD).map(p => ({ p, m: require(p) }))];
const matrix = [];
for (const f of FIXTURES) for (const v of f.variants) matrix.push([v, f.product]);
let ok = true;
← 3bccb9b2 auto-data-snapshot: 2026-09-22T13:21:57 (2 data files) — sho
·
back to Designer Wallcoverings
·
TK-11786: cadence setInventory2026 retries index-lag SKUs th e11fa699 →