← back to Dw Yolo Loop
Add read-only Active/Draft gate-integrity audit script (ACTIVE+DRAFT GraphQL paging)
20610f5dea090d40346e93e43ba5308bb3353464 · 2026-06-14 22:23:41 -0700 · Steve Abrams
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Files touched
M .gitignoreA gate-integrity-audit.mjs
Diff
commit 20610f5dea090d40346e93e43ba5308bb3353464
Author: Steve Abrams <steve@designerwallcoverings.com>
Date: Sun Jun 14 22:23:41 2026 -0700
Add read-only Active/Draft gate-integrity audit script (ACTIVE+DRAFT GraphQL paging)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
---
.gitignore | 1 +
gate-integrity-audit.mjs | 187 +++++++++++++++++++++++++++++++++++++++++++++++
2 files changed, 188 insertions(+)
diff --git a/.gitignore b/.gitignore
index 1fdebd3..b157c45 100644
--- a/.gitignore
+++ b/.gitignore
@@ -10,3 +10,4 @@ build/
uploads/
*.bak
.newwall-ref/
+audit.progress.log
diff --git a/gate-integrity-audit.mjs b/gate-integrity-audit.mjs
new file mode 100644
index 0000000..8a84d1f
--- /dev/null
+++ b/gate-integrity-audit.mjs
@@ -0,0 +1,187 @@
+#!/usr/bin/env node
+// Active/Draft gate-integrity audit — READ-ONLY. No writes, no status changes.
+// Scope: ACTIVE + DRAFT only (ARCHIVED skipped per scope; April restore-prep covers it).
+import fs from 'node:fs';
+
+const SHOP = 'designer-laboratory-sandbox.myshopify.com';
+const VER = '2024-10';
+
+// Load token from secrets .env without sourcing (file has a parse-unsafe line)
+const envTxt = fs.readFileSync(process.env.HOME + '/Projects/secrets-manager/.env', 'utf8');
+const m = envTxt.match(/^SHOPIFY_ADMIN_TOKEN=(.+)$/m);
+if (!m) { console.error('no token'); process.exit(1); }
+const TOKEN = m[1].trim();
+
+const URL = `https://${SHOP}/admin/api/${VER}/graphql.json`;
+
+async function gql(query, variables) {
+ for (let attempt = 0; attempt < 6; attempt++) {
+ const res = await fetch(URL, {
+ method: 'POST',
+ headers: { 'X-Shopify-Access-Token': TOKEN, 'Content-Type': 'application/json' },
+ body: JSON.stringify({ query, variables }),
+ });
+ const j = await res.json();
+ if (j.errors) {
+ const throttled = JSON.stringify(j.errors).includes('THROTTLED');
+ if (throttled) { await sleep(2000 * (attempt + 1)); continue; }
+ throw new Error(JSON.stringify(j.errors));
+ }
+ // gentle pacing based on remaining cost budget
+ const t = j.extensions?.cost?.throttleStatus;
+ if (t && t.currentlyAvailable < 500) await sleep(1500);
+ return j.data;
+ }
+ throw new Error('exhausted retries (throttle)');
+}
+const sleep = (ms) => new Promise(r => setTimeout(r, ms));
+
+const QUERY = `
+query($cursor: String, $q: String!) {
+ products(first: 50, after: $cursor, query: $q) {
+ pageInfo { hasNextPage endCursor }
+ nodes {
+ id
+ handle
+ title
+ status
+ vendor
+ tags
+ totalInventory
+ mediaCount { count }
+ variants(first: 30) { nodes { sku price } }
+ widthGlobal: metafield(namespace: "global", key: "width") { value }
+ widthCustom: metafield(namespace: "custom", key: "width") { value }
+ widthDwc: metafield(namespace: "dwc", key: "width") { value }
+ }
+ }
+}`;
+
+function hasWidth(p) {
+ const v = (p.widthGlobal?.value || p.widthCustom?.value || p.widthDwc?.value || '').trim();
+ return v.length > 0;
+}
+function hasImage(p) {
+ return (p.mediaCount?.count || 0) > 0;
+}
+// derive a base SKU for sample detection: find a non-sample variant SKU
+function variantSkus(p) {
+ return (p.variants?.nodes || []).map(v => (v.sku || '').trim()).filter(Boolean);
+}
+function hasSampleVariant(p) {
+ const skus = variantSkus(p);
+ // any variant whose sku ends with -Sample (case-insensitive)
+ return skus.some(s => /-sample$/i.test(s));
+}
+function looksDiscontinued(p) {
+ const t = (p.tags || []).map(x => x.toLowerCase());
+ const blob = (t.join(' ') + ' ' + (p.title || '').toLowerCase());
+ return /discontinu|disco\b|no longer avail|obsolete/.test(blob);
+}
+
+async function pageStatus(status, out) {
+ let cursor = null, hasNext = true, n = 0;
+ const q = `status:${status}`;
+ while (hasNext) {
+ const data = await gql(QUERY, { cursor, q });
+ const page = data.products;
+ for (const p of page.nodes) {
+ out.push(p);
+ n++;
+ }
+ hasNext = page.pageInfo.hasNextPage;
+ cursor = page.pageInfo.endCursor;
+ if (n % 500 === 0) process.stderr.write(` ${status}: ${n}\n`);
+ }
+ process.stderr.write(` ${status}: DONE ${n}\n`);
+ return n;
+}
+
+(async () => {
+ const all = [];
+ process.stderr.write('Paging ACTIVE...\n');
+ const nActive = await pageStatus('ACTIVE', all);
+ process.stderr.write('Paging DRAFT...\n');
+ const nDraft = await pageStatus('DRAFT', all);
+
+ const buckets = {
+ draft_gate_clean: [], // DRAFT w/ image+width -> reactivation candidate
+ active_missing_image: [], // ACTIVE no image
+ active_missing_width: [], // ACTIVE no width metafield
+ active_missing_sample: [], // ACTIVE no {SKU}-Sample variant
+ draft_looks_discontinued: [], // DRAFT that should be ARCHIVED
+ };
+
+ const vc = (obj, k) => { obj[k] = (obj[k] || 0) + 1; };
+ const vendorTally = {
+ draft_gate_clean: {},
+ active_missing_image: {},
+ active_missing_width: {},
+ active_missing_sample: {},
+ draft_looks_discontinued: {},
+ };
+
+ for (const p of all) {
+ const row = {
+ id: p.id.split('/').pop(),
+ handle: p.handle,
+ title: p.title,
+ vendor: p.vendor || '(none)',
+ status: p.status,
+ images: p.mediaCount?.count || 0,
+ width: hasWidth(p),
+ hasSample: hasSampleVariant(p),
+ skus: variantSkus(p),
+ tags: p.tags,
+ };
+ if (p.status === 'DRAFT') {
+ if (hasImage(p) && hasWidth(p) && !looksDiscontinued(p)) {
+ buckets.draft_gate_clean.push(row); vc(vendorTally.draft_gate_clean, row.vendor);
+ }
+ if (looksDiscontinued(p)) {
+ buckets.draft_looks_discontinued.push(row); vc(vendorTally.draft_looks_discontinued, row.vendor);
+ }
+ } else if (p.status === 'ACTIVE') {
+ if (!hasImage(p)) { buckets.active_missing_image.push(row); vc(vendorTally.active_missing_image, row.vendor); }
+ if (!hasWidth(p)) { buckets.active_missing_width.push(row); vc(vendorTally.active_missing_width, row.vendor); }
+ if (!hasSampleVariant(p)) { buckets.active_missing_sample.push(row); vc(vendorTally.active_missing_sample, row.vendor); }
+ }
+ }
+
+ const topVendors = (obj, n = 15) =>
+ Object.entries(obj).sort((a, b) => b[1] - a[1]).slice(0, n).map(([v, c]) => ({ vendor: v, count: c }));
+
+ const result = {
+ generated_at: new Date().toISOString(),
+ scope: 'ACTIVE + DRAFT (ARCHIVED skipped per scope; April restore-prep covers archived)',
+ shop: SHOP,
+ api_version: VER,
+ read_only: true,
+ counts: {
+ scanned_active: nActive,
+ scanned_draft: nDraft,
+ total_scanned: all.length,
+ draft_gate_clean: buckets.draft_gate_clean.length,
+ active_missing_image: buckets.active_missing_image.length,
+ active_missing_width: buckets.active_missing_width.length,
+ active_missing_sample: buckets.active_missing_sample.length,
+ draft_looks_discontinued: buckets.draft_looks_discontinued.length,
+ },
+ top_vendors: {
+ draft_gate_clean: topVendors(vendorTally.draft_gate_clean),
+ active_missing_image: topVendors(vendorTally.active_missing_image),
+ active_missing_width: topVendors(vendorTally.active_missing_width),
+ active_missing_sample: topVendors(vendorTally.active_missing_sample),
+ draft_looks_discontinued: topVendors(vendorTally.draft_looks_discontinued),
+ },
+ rows: buckets,
+ };
+
+ const outPath = process.env.HOME + '/.claude/yolo-queue/gate-integrity-audit-2026-06-14.json';
+ fs.mkdirSync(process.env.HOME + '/.claude/yolo-queue', { recursive: true });
+ fs.writeFileSync(outPath, JSON.stringify(result, null, 2));
+ process.stderr.write('WROTE ' + outPath + '\n');
+ // emit summary counts to stdout
+ console.log(JSON.stringify(result.counts, null, 2));
+ console.log('TOP draft_gate_clean:', JSON.stringify(result.top_vendors.draft_gate_clean.slice(0,8)));
+})();
← d086ddd Add fail-closed pre-flight validator (rules.json + preflight
·
back to Dw Yolo Loop
·
brands page: consolidate Scalamandré to single /collections 451c923 →