← back to Designerwallcoverings
TK-11070: modal-code hero correction (fixes inverted hero-selection) + delete-safety guard
fdfca8806918e7664c435b3b4027536ef3d74752 · 2026-09-01 16:11:25 -0700 · Steve Abrams
hero-modal.mjs: shared resolveHero() drop-in — fetches the live colorway page,
picks the DOMINANT own-mfr-image-code as hero (de-prioritizes the shared og default),
returns typed {code,file,url,confidence} so destructive callers refuse unconfirmed heroes.
build-payloads.mjs: --live-heroes uses the modal hero as position-1 (high-confidence only,
else DB default). tk11070-remediate.mjs: media-DELETE now hard-gated on a confirmed live hero
(no confirmed hero => no delete; plain run deletes no media). Canary + dry-run only; publish gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Files touched
M scripts/sanderson-onboard/build-payloads.mjsA scripts/sanderson-onboard/hero-modal.mjsA scripts/sanderson-onboard/tk11070-hero-canary.mjsM scripts/sanderson-onboard/tk11070-remediate.mjs
Diff
commit fdfca8806918e7664c435b3b4027536ef3d74752
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Tue Sep 1 16:11:25 2026 -0700
TK-11070: modal-code hero correction (fixes inverted hero-selection) + delete-safety guard
hero-modal.mjs: shared resolveHero() drop-in — fetches the live colorway page,
picks the DOMINANT own-mfr-image-code as hero (de-prioritizes the shared og default),
returns typed {code,file,url,confidence} so destructive callers refuse unconfirmed heroes.
build-payloads.mjs: --live-heroes uses the modal hero as position-1 (high-confidence only,
else DB default). tk11070-remediate.mjs: media-DELETE now hard-gated on a confirmed live hero
(no confirmed hero => no delete; plain run deletes no media). Canary + dry-run only; publish gated.
Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
scripts/sanderson-onboard/build-payloads.mjs | 38 ++++++--
scripts/sanderson-onboard/hero-modal.mjs | 104 ++++++++++++++++++++++
scripts/sanderson-onboard/tk11070-hero-canary.mjs | 65 ++++++++++++++
scripts/sanderson-onboard/tk11070-remediate.mjs | 43 +++++++--
4 files changed, 240 insertions(+), 10 deletions(-)
diff --git a/scripts/sanderson-onboard/build-payloads.mjs b/scripts/sanderson-onboard/build-payloads.mjs
index 386eb6b..0a076b1 100644
--- a/scripts/sanderson-onboard/build-payloads.mjs
+++ b/scripts/sanderson-onboard/build-payloads.mjs
@@ -23,6 +23,15 @@ import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
import { specMetafields } from '../_spec-to-metafields.mjs';
+import { resolveHero } from './hero-modal.mjs'; // TK-11070 modal-code hero (fixes shared-default inversion)
+
+// TK-11070: opt-in live hero resolution. Default OFF so a plain re-run stays offline/deterministic and
+// does not fire ~500 vendor fetches unexpectedly. With --live-heroes, images() uses the modal-code
+// dominant own-code hero as position-1 (instead of the shared DB image_url default). See hero-modal.mjs.
+const LIVE_HEROES = process.argv.includes('--live-heroes');
+const HERO_ONLY = ((process.argv.find(a => a.startsWith('--hero-only=')) || '').split('=')[1] || '')
+ .split(',').filter(Boolean).map(s => s.toUpperCase()); // limit live pass to these mfr_skus (canary)
+const heroCache = new Map(); // mfr_sku(upper) → resolved hero result
const HERE = path.dirname(fileURLToPath(import.meta.url));
const OUT = path.join(HERE, 'out');
@@ -183,10 +192,15 @@ function sawKey(s) { // normalize "SAW0189-02" | "SAW0189_
const m = String(s || '').match(SAW_IN_NAME);
return m ? m[0].toUpperCase().replace(/[^A-Z0-9]/g, '') : null;
}
-function images(r) {
+// heroOverride: optional {url, confidence, source} from the TK-11070 modal-code resolver. When present
+// AND confidence==='high', its URL becomes position-1 (the correct per-colorway hero) INSTEAD of the
+// shared DB image_url default. Low/none confidence (or no override) → fall back to r.image_url exactly
+// as before, so behavior is unchanged when live resolution is off or unconfident.
+function images(r, heroOverride) {
const seen = new Set(); const imgs = [];
const push = u => { if (u && !seen.has(u)) { seen.add(u); imgs.push({ src: u, alt: esc(titleCase(r.pattern_name) || r.mfr_sku) }); } };
- push(r.image_url); // vendor-canonical hero — always position 1
+ const heroUrl = (heroOverride && heroOverride.confidence === 'high' && heroOverride.url) ? heroOverride.url : r.image_url;
+ push(heroUrl); // position-1 hero (modal-code corrected when high-confidence, else DB default)
const own = sawKey(r.mfr_sku); // this product's own article, e.g. SAW018902
for (const g of arr(r.gallery_images)) {
// keep ONLY gallery images whose filename embeds THIS product's own SAW code; drop foreign plates.
@@ -195,9 +209,10 @@ function images(r) {
return imgs;
}
-function main() {
+async function main() {
const data = rows();
const out = [];
+ let heroFixed = 0, heroLow = 0;
const skuAssign = []; // [id, dw_sku] to persist for rows that had none
let idx = 0, noImg = 0, noPrice = 0;
const seenSku = new Set();
@@ -211,7 +226,19 @@ function main() {
let dw = r.dw_sku;
if (!dw) { dw = nextSku(); skuAssign.push([r.id, dw]); }
if (seenSku.has(dw)) continue; seenSku.add(dw);
- const imgs = images(r);
+ // TK-11070: resolve the correct per-colorway hero from the LIVE page when --live-heroes is set
+ // (optionally scoped to --hero-only=<mfr,...> for a canary). Cached per mfr_sku.
+ let heroOverride = null;
+ if (LIVE_HEROES && r.product_url && (!HERO_ONLY.length || HERO_ONLY.includes(String(r.mfr_sku).toUpperCase()))) {
+ const key = String(r.mfr_sku).toUpperCase();
+ if (heroCache.has(key)) heroOverride = heroCache.get(key);
+ else { heroOverride = await resolveHero(r.product_url, { dbImageUrl: r.image_url }); heroCache.set(key, heroOverride); }
+ if (heroOverride) {
+ if (heroOverride.confidence === 'high' && heroOverride.source === 'live-frequency') heroFixed++;
+ else heroLow++;
+ }
+ }
+ const imgs = images(r, heroOverride);
if (!imgs.length) noImg++;
const priced = r.retail_usd != null && Number(r.retail_usd) > 0;
if (!priced) noPrice++;
@@ -241,6 +268,7 @@ function main() {
}
fs.writeFileSync(path.join(OUT, 'payloads.jsonl'), out.map(o => JSON.stringify(o)).join('\n') + '\n');
console.log(`payloads: ${out.length} (dw_sku newly-assigned ${skuAssign.length}, no-image ${noImg}, unpriced ${noPrice})`);
+ if (LIVE_HEROES) console.log(`TK-11070 live heroes: ${heroFixed} high-confidence modal-code · ${heroLow} low/none → DB-default fallback`);
const clr = out.filter(o => o.settlement_verdict === 'CLEAR').length;
console.log(`settlement: CLEAR ${clr} · REVIEW/other ${out.length - clr} (only CLEAR auto-goes-live)`);
const noWidth = out.filter(o => !o.metafields.some(m => m.namespace === 'global' && m.key === 'width')).length;
@@ -252,4 +280,4 @@ function main() {
console.log(` images: ${o.product.images.length} · metafields(${o.metafields.length}): ${o.metafields.map(m => m.namespace+'.'+m.key).join(', ')}`);
}
}
-main();
+main(); // top-level async (invoked; errors surface as an unhandled rejection = non-zero exit)
diff --git a/scripts/sanderson-onboard/hero-modal.mjs b/scripts/sanderson-onboard/hero-modal.mjs
new file mode 100644
index 0000000..1588cc6
--- /dev/null
+++ b/scripts/sanderson-onboard/hero-modal.mjs
@@ -0,0 +1,104 @@
+/**
+ * hero-modal.mjs — TK-11070 shared hero-selection (the MODAL-CODE method).
+ *
+ * THE BUG THIS FIXES (verified 2026-09-01, TK-11070):
+ * sanderson_catalog.image_url is a SHARED collapsed default (the vendor page og:image) that is
+ * IDENTICAL across every colorway of a pattern. e.g. all 6 Caspian colorways carry
+ * image_url = ".../DCPW216771_a89d.jpg" (Ivory's photo) even though Grass's own hero is DCPW216772.
+ * Using image_url as the position-1 hero therefore puts Ivory's photo on Grass — the "look-alike" bug.
+ * gallery_images is no better offline: it is a shared default, empty, or a rolling window of
+ * FOREIGN cross-pattern plates (DCAC..., DCEF...). The correct per-colorway hero code exists ONLY in the
+ * LIVE product page HTML.
+ *
+ * THE CORRECT SIGNAL (proven in ~/Projects/dw-newarrivals-fix/modal.mjs + pw-modal.cjs):
+ * On a colorway's OWN vendor product page, its correct image code renders ~28x (the dominant gallery
+ * hero) while the shared og default appears ~7x. So: fetch the live product_url, regex every
+ * /<CODE>_<hex>.jpg image file, count code frequency, and pick the MOST FREQUENT code as the true
+ * hero. That frequency ranking DE-PRIORITIZES the shared og default automatically.
+ *
+ * SAFETY CONTRACT (Codex-reviewed 2026-09-01):
+ * resolveHero() returns a TYPED result with an explicit confidence so a DESTRUCTIVE caller
+ * (tk11070-remediate media DELETE) can refuse to act on an unconfirmed hero:
+ * { code, file, url, confidence: 'high'|'low'|'none', source, og, ranked, counts }
+ * - 'high' : live fetch OK AND the top code clearly dominates (dominance rule below).
+ * - 'low' : live fetch OK but dominance ambiguous (top code not clearly ahead / equals the og default).
+ * - 'none' : fetch/parse failed — nothing usable from the page.
+ * When confidence is 'low'/'none', callers fall back to the DB image_url for PAYLOAD COMPLETENESS
+ * only (source='db-fallback'); the DB fallback is NEVER trusted for a destructive delete decision.
+ *
+ * HARD RULE for destructive callers: no confirmed ('high') hero => no hero-driven media delete.
+ */
+import { setTimeout as delay } from 'node:timers/promises';
+
+const UA = 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/605.1.15 (KHTML, like Gecko) Version/16 Safari/605.1.15';
+// Sanderson media codes look like DCPW216771 / DABW217233 / DYSI216609 — 2-6 letters + 4-7 digits.
+const CODE_IN_FILE = /\/([A-Z]{2,6}\d{4,7})_([0-9a-f]{3,})\.jpg/gi;
+const OG_CODE = /og:image[^>]*content="[^"]*\/([A-Z]{2,6}\d{4,7})_/i;
+const CODE_RX = /([A-Z]{2,6}\d{4,7})/i;
+
+export const codeOf = s => { const m = String(s || '').match(CODE_RX); return m ? m[1].toUpperCase() : null; };
+export const mediaUrlForCode = (code, file) =>
+ `https://www.sanderson.design/static/media/catalog/product/${code[0]}/${code[1]}/${file}`;
+
+/**
+ * Parse the live product-page HTML into a ranked hero decision. Pure function — unit-testable.
+ * Dominance rule for 'high' confidence:
+ * - a top code must exist AND appear >= MIN_TOP times, AND
+ * - it must NOT be the og default UNLESS it also strictly out-counts every other code, AND
+ * - it must lead the 2nd-place code by >= LEAD (strictly more frequent — a tie is NOT dominant).
+ * These thresholds encode "the own-code hero repeats far more than the shared default."
+ */
+export function heroFromHtml(html, { minTop = 3, lead = 1 } = {}) {
+ if (!html || typeof html !== 'string') return { code: null, file: null, confidence: 'none', source: 'unresolved', og: null, ranked: [], counts: {} };
+ const og = (html.match(OG_CODE) || [])[1] ? (html.match(OG_CODE) || [])[1].toUpperCase() : null;
+ const counts = {}; const fileByCode = {};
+ for (const m of html.matchAll(CODE_IN_FILE)) {
+ const c = m[1].toUpperCase();
+ counts[c] = (counts[c] || 0) + 1;
+ if (!fileByCode[c]) fileByCode[c] = `${m[1]}_${m[2]}.jpg`;
+ }
+ const ranked = Object.entries(counts).sort((a, b) => b[1] - a[1]);
+ if (!ranked.length) return { code: null, file: null, confidence: 'none', source: 'unresolved', og, ranked: [], counts };
+ const [topCode, topCount] = ranked[0];
+ const secondCount = ranked[1] ? ranked[1][1] : 0;
+ // dominance: strictly leads 2nd place by >= lead, and clears the min-repeat floor.
+ const clearlyDominant = topCount >= minTop && (topCount - secondCount) >= lead;
+ // if the top code IS the og default, it must STRICTLY out-count everyone else to count as dominant
+ // (guards the case where the shared default happens to appear most because the page is sparse).
+ const okVsOg = topCode !== og || topCount > secondCount;
+ const confidence = clearlyDominant && okVsOg ? 'high' : 'low';
+ return {
+ code: topCode, file: fileByCode[topCode], url: mediaUrlForCode(topCode, fileByCode[topCode]),
+ confidence, source: 'live-frequency', og, ranked: ranked.slice(0, 4), counts,
+ };
+}
+
+/**
+ * Fetch a live Sanderson product page (plain fetch, retried) and resolve the hero.
+ * Returns the typed result. On fetch failure → confidence:'none'. Never throws.
+ * @param {string} productUrl the colorway's own vendor product URL
+ * @param {object} opts { dbImageUrl, attempts, minTop, lead }
+ */
+export async function resolveHero(productUrl, opts = {}) {
+ const { dbImageUrl = null, attempts = 6, minTop = 3, lead = 1 } = opts;
+ let html = null;
+ if (productUrl) {
+ for (let i = 0; i < attempts; i++) {
+ try {
+ const res = await fetch(productUrl, {
+ headers: { 'User-Agent': UA, 'Accept-Language': 'en-US,en;q=0.9', 'Referer': 'https://www.sanderson.design/' },
+ signal: AbortSignal.timeout(30000),
+ });
+ const t = await res.text();
+ if (t && t.length > 50000) { html = t; break; }
+ } catch { /* retry */ }
+ await delay(1500 + i * 700);
+ }
+ }
+ const r = heroFromHtml(html, { minTop, lead });
+ // low/none → DB fallback for PAYLOAD COMPLETENESS ONLY (never trusted for a destructive delete).
+ if (r.confidence !== 'high' && dbImageUrl) {
+ return { ...r, code: codeOf(dbImageUrl), file: String(dbImageUrl).split('/').pop().split('?')[0], url: dbImageUrl, source: 'db-fallback' };
+ }
+ return r;
+}
diff --git a/scripts/sanderson-onboard/tk11070-hero-canary.mjs b/scripts/sanderson-onboard/tk11070-hero-canary.mjs
new file mode 100644
index 0000000..815850a
--- /dev/null
+++ b/scripts/sanderson-onboard/tk11070-hero-canary.mjs
@@ -0,0 +1,65 @@
+#!/usr/bin/env node
+/**
+ * TK-11070 hero canary — DRY-RUN proof, ZERO writes, ZERO deletes.
+ *
+ * Proves the modal-code correction fixes the inversion on the Caspian canary set:
+ * Ivory=SAW0140-01 Grass=SAW0140-02 Silver=SAW0140-04 Teal=SAW0140-05 Taupe=SAW0140-06
+ * For each colorway shows:
+ * BEFORE (inverted) = the DB image_url hero build-payloads USED (push(r.image_url)) — the shared default.
+ * AFTER (corrected) = resolveHero(product_url) modal-code dominant own-code hero.
+ * The proof: AFTER must KEEP each colorway's OWN dominant code (Grass keeps 216772, NOT 216771).
+ *
+ * node tk11070-hero-canary.mjs
+ */
+import { execSync } from 'node:child_process';
+import { resolveHero, codeOf } from './hero-modal.mjs';
+
+const DB = 'postgresql:///dw_unified?host=/tmp';
+// canary set from the BLOCKER memo (Silver=SAW0140-04, Teal=..05, Taupe=..06; Ivory/Grass ..01/..02)
+const CANARY = ['SAW0140-01', 'SAW0140-02', 'SAW0140-04', 'SAW0140-05', 'SAW0140-06'];
+
+const q = `select mfr_sku, color_name, image_url, product_url
+ from sanderson_catalog
+ where mfr_sku in (${CANARY.map(s => `'${s}'`).join(',')})
+ order by mfr_sku`;
+const rows = execSync(`psql "${DB}" -tAc "${q}"`, { encoding: 'utf8' }).trim().split('\n').filter(Boolean)
+ .map(l => { const [mfr, color, image_url, product_url] = l.split('|'); return { mfr, color, image_url, product_url }; });
+
+console.log('TK-11070 HERO CANARY — Caspian Strié (DRY-RUN, no writes/deletes)\n');
+console.log('colorway | BEFORE (inverted: DB image_url) | AFTER (corrected: modal-code) | conf | verdict');
+console.log('-----------------|---------------------------------|------------------------------------|--------|--------');
+
+const results = [];
+for (const r of rows) {
+ const beforeCode = codeOf(r.image_url); // what build-payloads USED = shared default
+ const hero = await resolveHero(r.product_url, { dbImageUrl: r.image_url });
+ const afterCode = hero.code;
+ // Correct iff the corrected hero differs from the shared default AND confidence is high
+ // (a real per-colorway own-code, not the collapsed Ivory default).
+ const distinctFromDefault = afterCode && afterCode !== beforeCode;
+ const verdict = hero.source === 'live-frequency' && hero.confidence === 'high' && distinctFromDefault
+ ? 'FIXED ✓'
+ : hero.confidence === 'high' && afterCode === beforeCode ? 'same(=default?)' : `check(${hero.source}/${hero.confidence})`;
+ results.push({ ...r, beforeCode, afterCode, hero, verdict });
+ const rk = hero.ranked.map(([c, n]) => `${c}×${n}`).join(' ');
+ console.log(
+ `${(r.color + ' (' + r.mfr + ')').padEnd(16)} | ${String(beforeCode).padEnd(31)} | ${String(afterCode).padEnd(34)} | ${String(hero.confidence).padEnd(6)} | ${verdict}`);
+ console.log(` ranked(top4): ${rk} og=${hero.og} source=${hero.source}`);
+}
+
+console.log('\n--- KEEP / DROP proof (per colorway) ---');
+for (const x of results) {
+ const keep = x.afterCode;
+ const drop = x.beforeCode !== x.afterCode ? x.beforeCode : '(none — before==after)';
+ console.log(`${x.color.padEnd(8)} KEEP ${keep} DROP ${drop} [before-inverted kept ${x.beforeCode}]`);
+}
+
+const grass = results.find(r => r.color === 'Grass');
+console.log('\n--- THE MONEY CHECK (from the BLOCKER memo) ---');
+if (grass) {
+ const pass = grass.afterCode === 'DCPW216772' && grass.beforeCode === 'DCPW216771';
+ console.log(`Grass: corrected KEEP = ${grass.afterCode} (want DCPW216772, NOT the shared Ivory default DCPW216771)`);
+ console.log(`Inverted logic would have kept ${grass.beforeCode} (Ivory's photo on Grass).`);
+ console.log(pass ? '✅ CANARY PASS — inversion is FIXED. Grass keeps its OWN dominant code, drops the shared default.'
+ : '❌ CANARY CHECK — Grass did not resolve to DCPW216772; inspect ranked/confidence above.');
+}
diff --git a/scripts/sanderson-onboard/tk11070-remediate.mjs b/scripts/sanderson-onboard/tk11070-remediate.mjs
index f943fdf..6dc4418 100644
--- a/scripts/sanderson-onboard/tk11070-remediate.mjs
+++ b/scripts/sanderson-onboard/tk11070-remediate.mjs
@@ -19,9 +19,18 @@ import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';
import { execSync } from 'node:child_process';
+import { resolveHero, codeOf as heroCodeOf } from './hero-modal.mjs'; // TK-11070 modal-code hero guard
const HERE = path.dirname(fileURLToPath(import.meta.url));
const DB = 'postgresql:///dw_unified?host=/tmp';
+// TK-11070 DELETE SAFETY (Codex-reviewed 2026-09-01): the media DELETE must never remove a correct
+// per-colorway image on an UNCONFIRMED hero. --live-heroes turns on the guard: for each product we
+// resolve the live modal-code hero and (a) PROTECT that high-confidence hero file from deletion, and
+// (b) when the hero is low/none-confidence, we SKIP the media-delete for that product entirely (tags
+// still fixed). HARD RULE: no confirmed hero ⇒ no hero-driven media delete. When the flag is OFF the
+// guard forces the conservative posture too — media-delete is disabled unless a live hero confirms it,
+// so a plain run can never delete a correct image based on a possibly-inverted payload keep-set.
+const LIVE_HEROES = process.argv.includes('--live-heroes');
const STORE = process.env.SHOPIFY_STORE || 'designer-laboratory-sandbox.myshopify.com';
const TOKEN = process.env.SHOPIFY_ADMIN_TOKEN;
const API = process.env.SHOPIFY_API_VERSION || '2024-10';
@@ -119,13 +128,13 @@ function pgGallery(mfrSku) {
// map upper(mfr_sku) → {color_name, pattern_name, ai_palette:Set} for surgical tag repair
function pgColorMap() {
- const raw = execSync(`psql "${DB}" -tAc "select json_agg(json_build_object('mfr',mfr_sku,'color',color_name,'pattern',pattern_name,'bg',ai_background_color,'colors',ai_colors)) from sanderson_catalog"`, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
+ const raw = execSync(`psql "${DB}" -tAc "select json_agg(json_build_object('mfr',mfr_sku,'color',color_name,'pattern',pattern_name,'bg',ai_background_color,'colors',ai_colors,'url',product_url,'img',image_url)) from sanderson_catalog"`, { encoding: 'utf8', maxBuffer: 64 * 1024 * 1024 }).trim();
const m = new Map();
for (const r of JSON.parse(raw || '[]')) {
const palette = new Set(
[...(Array.isArray(r.colors) ? r.colors.map(c => c && (c.name || c.color)) : []), r.bg]
.filter(Boolean).map(x => titleCase(x)));
- m.set(String(r.mfr).toUpperCase(), { color: r.color, pattern: r.pattern, palette });
+ m.set(String(r.mfr).toUpperCase(), { color: r.color, pattern: r.pattern, palette, product_url: r.url, image_url: r.img });
}
return m;
}
@@ -185,15 +194,37 @@ async function main() {
const currentTags = p.tags;
const newTags = surgicalTags(currentTags, cleanColor, cm.palette || new Set());
const media = p.media.nodes.map(m => ({ id: m.id, url: (m.image && m.image.url) || '', file: fileOf(m.image && m.image.url) }));
- const delMedia = media.filter(m => !correctFiles.has(m.file));
- const keepMedia = media.filter(m => correctFiles.has(m.file));
+
+ // ---- TK-11070 DELETE SAFETY GUARD (modal-code hero) ----
+ // Resolve the colorway's TRUE hero from its live vendor page. On a high-confidence result:
+ // • protect that hero's file from deletion (add it to the keep-set), and
+ // • the hero-driven media delete is ALLOWED (payload keep-set + confirmed live hero).
+ // On low/none confidence (or no live pass): DISABLE the media delete for this product entirely
+ // (still fix tags) — never delete a correct image on an unconfirmed hero. This makes remediate
+ // safe regardless of whether the payload's position-1 hero was the correct one or the shared default.
+ let heroGuard = { confidence: 'none', source: 'off', code: null, file: null };
+ if (LIVE_HEROES && cm.product_url) {
+ heroGuard = await resolveHero(cm.product_url, { dbImageUrl: cm.image_url });
+ if (heroGuard.confidence === 'high' && heroGuard.source === 'live-frequency' && heroGuard.file) {
+ correctFiles.add(fileOf(heroGuard.url || heroGuard.file)); // PROTECT the confirmed hero
+ }
+ }
+ const heroConfirmed = heroGuard.confidence === 'high' && heroGuard.source === 'live-frequency';
+ const mediaDeleteAllowed = heroConfirmed; // no confirmed hero ⇒ no hero-driven delete
+
+ const delMediaRaw = media.filter(m => !correctFiles.has(m.file));
+ const delMedia = mediaDeleteAllowed ? delMediaRaw : []; // gated by the live-hero guard
+ const keepMedia = media.filter(m => !delMedia.some(d => d.id === m.id));
+ const mediaDeleteBlocked = delMediaRaw.length > 0 && !mediaDeleteAllowed;
const tagsChanged = JSON.stringify([...currentTags].sort()) !== JSON.stringify([...newTags].sort());
- if (!tagsChanged && delMedia.length === 0) continue; // already clean
+ if (!tagsChanged && delMedia.length === 0) continue; // already clean (tags fine + no allowed deletes)
plans.push({
id: p.id, handle: p.handle, status: p.status, mfr: String(resolvedMfr || ''),
colorway: cleanColor,
+ hero: heroConfirmed ? heroGuard.code : null, heroConf: heroGuard.confidence, heroSrc: heroGuard.source,
+ mediaDeleteBlocked, delMediaRaw,
oldTags: currentTags, newTags,
oldMedia: media, delMedia, keepMedia,
correctFiles: [...correctFiles],
@@ -217,8 +248,10 @@ async function main() {
const addC = pl.newTags.filter(t => !pl.oldTags.includes(t));
const remC = pl.oldTags.filter(t => !pl.newTags.includes(t));
console.log(`\n[${pl.status}] ${pl.handle} (${pl.mfr})`);
+ if (pl.hero) console.log(` hero(modal-code): ${pl.hero} [${pl.heroConf}/${pl.heroSrc}] — protected from delete`);
if (addC.length || remC.length) console.log(` tags +[${addC.join(', ')}] -[${remC.join(', ')}]`);
if (pl.delMedia.length) console.log(` media: keep ${pl.keepMedia.map(m => m.file).join(', ') || '(none!)'} · DROP ${pl.delMedia.length}: ${pl.delMedia.map(m => m.file).join(', ')}`);
+ if (pl.mediaDeleteBlocked) console.log(` 🛡 media-delete BLOCKED (hero unconfirmed: ${pl.heroConf}/${pl.heroSrc}) — ${pl.delMediaRaw.length} candidate drop(s) HELD to avoid deleting a correct image`);
// SAFETY: never strip to zero images
if (pl.keepMedia.length === 0 && pl.oldMedia.length > 0) console.log(` ⚠ WARN: keep-set empty — would leave 0 images; will SKIP media delete for this product`);
}
← 6121aae auto-data-snapshot: 2026-09-01T16:07:10 (5 data files) — scr
·
back to Designerwallcoverings
·
auto-data-snapshot: 2026-09-01T16:44:49 (4 data files) — scr bee69fa →