← back to Dw Yolo Loop
gate-integrity-audit.mjs
188 lines
#!/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)));
})();