← 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)));
})();