← back to Tk 10965 Zero Price Analysis

tk10964-canary-guard/check.mjs

134 lines

#!/usr/bin/env node
// TK-10964 — read-only zero-price-orderable guard.
// Queries cover the full quote-tag family and a vendor fallback for the known
// untagged Fentucci cohort. Results are de-duplicated by Shopify product GID.
import fs from 'node:fs';
import path from 'node:path';
import { fileURLToPath } from 'node:url';

const MODULE_PATH = fileURLToPath(import.meta.url);
const HERE = path.dirname(MODULE_PATH);
const FIXTURE_MODE = process.argv.includes('--fixture');
// Fixture invocations are probes/tests, not scheduler heartbeats. They must not
// overwrite the production artifact unless the caller deliberately supplies an
// isolated output path.
const OUT = process.env.ZERO_PRICE_CANARY_OUT || (FIXTURE_MODE ? null : path.join(HERE, 'data', 'latest.json'));
export const SEARCHES = [
  `status:active AND (tag:'quote-only' OR tag:'Quote Only' OR tag:'quote_only' OR tag:'Quote-Only')`,
  `status:active AND (tag:'quotes' OR tag:'contact-for-price' OR tag:'Needs-Price')`,
  `status:active AND vendor:'Fentucci Naturals'`
];

const QUOTE_TAGS = new Set(['quote-only', 'quote only', 'quote_only', 'quotes', 'contact-for-price', 'needs-price']);
export function inScope(product) {
  return product.vendor === 'Fentucci Naturals' || (product.tags || []).some(tag => QUOTE_TAGS.has(String(tag).trim().toLowerCase()));
}

export function badVariant(product) {
  return product.variants.nodes.find(variant =>
    !/sample/i.test(variant.title || '') &&
    Number(variant.price) === 0 &&
    variant.availableForSale === true
  );
}

export function summarize(products, ts = new Date().toISOString()) {
  // Shopify's search grammar can over-return on nested OR expressions. Enforce
  // the intended scope locally as a second boundary before classifying defects.
  const unique = [...new Map(products.map(product => [product.id, product])).values()].filter(inScope);
  const bad = unique.flatMap(product => {
    const variant = badVariant(product);
    return variant ? [{ product, variant }] : [];
  });
  const verdict = bad.length === 0 ? 'PASS' : bad.length <= 5 ? 'WARN' : 'FAIL';
  return {
    skill: 'zero-price-orderable-canary', verdict, status: verdict, ts,
    searched_active_unique: unique.length,
    zero_price_orderable: bad.length,
    by_vendor: Object.fromEntries([...new Set(bad.map(row => row.product.vendor || 'UNKNOWN'))].sort().map(vendor => [vendor, bad.filter(row => (row.product.vendor || 'UNKNOWN') === vendor).length])),
    mechanism: {
      qty_2026: bad.filter(row => row.variant.inventoryQuantity === 2026).length,
      deny: bad.filter(row => row.variant.inventoryPolicy === 'DENY').length,
      tracked: bad.filter(row => row.variant.inventoryItem?.tracked === true).length
    },
    detail: verdict === 'PASS' ? 'no scoped active product has a $0 orderable non-sample variant' : `${bad.length} products have a $0 ORDERABLE non-sample variant`,
    sample_ids: bad.slice(0, 10).map(({ product, variant }) => ({ id: product.id.split('/').pop(), title: product.title, vendor: product.vendor, qty: variant.inventoryQuantity, policy: variant.inventoryPolicy }))
  };
}

function writeResult(result) {
  if (OUT) {
    fs.mkdirSync(path.dirname(OUT), { recursive: true });
    fs.writeFileSync(OUT, JSON.stringify(result, null, 2) + '\n');
  }
  console.log(`${result.verdict}: ${result.zero_price_orderable} zero-price-orderable of ${result.searched_active_unique} uniquely searched active`);
}

export function exitCodeFor(result) {
  if (result.verdict === 'PASS') return 0;
  if (result.verdict === 'WARN') return 2;
  return 3;
}

// Shopify can return products with more than 20 variants. The search query keeps
// its small first page for efficiency, then only products that advertise another
// variant page incur follow-up reads. This prevents a zero-price variant at #21+
// from becoming a silent false negative without issuing one query per product.
export async function hydrateVariantPages(products, gql) {
  for (const product of products) {
    let pageInfo = product.variants?.pageInfo;
    let after = pageInfo?.hasNextPage ? pageInfo.endCursor : null;
    while (after) {
      const data = await gql(`query($id:ID!,$after:String!){product(id:$id){variants(first:100,after:$after){pageInfo{hasNextPage endCursor} nodes{title price availableForSale inventoryPolicy inventoryQuantity inventoryItem{tracked}}}}}`, { id: product.id, after });
      const page = data.product?.variants;
      if (!page) throw new Error(`missing variant page for ${product.id}`);
      product.variants.nodes.push(...page.nodes);
      pageInfo = page.pageInfo;
      after = pageInfo.hasNextPage ? pageInfo.endCursor : null;
    }
  }
  return products;
}

async function liveProducts() {
  const env = fs.readFileSync('/Users/macstudio3/Projects/secrets-manager/.env', 'utf8');
  const val = key => (env.match(new RegExp(`^${key}=(.*)$`, 'm')) || [])[1]?.trim();
  const api = `https://${val('SHOPIFY_STORE_DOMAIN')}/admin/api/2024-10/graphql.json`;
  const token = val('SHOPIFY_ADMIN_TOKEN');
  async function gql(query, variables) {
    for (let attempt = 0; attempt < 6; attempt++) {
      const response = await fetch(api, { method: 'POST', headers: { 'X-Shopify-Access-Token': token, 'Content-Type': 'application/json' }, body: JSON.stringify({ query, variables }) });
      const json = await response.json();
      if (!json.errors) return json.data;
      if (!JSON.stringify(json.errors).includes('THROTTLED')) throw new Error(JSON.stringify(json.errors));
      await new Promise(resolve => setTimeout(resolve, 1800 * (attempt + 1)));
    }
    throw new Error('GraphQL retries exhausted');
  }
  const products = [];
  for (const q of SEARCHES) {
    let after = null;
    do {
      const data = await gql(`query($q:String!,$after:String){products(first:100,query:$q,after:$after){pageInfo{hasNextPage endCursor} nodes{id title vendor tags variants(first:20){pageInfo{hasNextPage endCursor} nodes{title price availableForSale inventoryPolicy inventoryQuantity inventoryItem{tracked}}}}}}`, { q, after });
      products.push(...data.products.nodes);
      after = data.products.pageInfo.hasNextPage ? data.products.pageInfo.endCursor : null;
    } while (after);
  }
  return hydrateVariantPages(products, gql);
}

async function main() {
  const fixtureArg = process.argv.indexOf('--fixture');
  const products = fixtureArg >= 0 ? JSON.parse(fs.readFileSync(process.argv[fixtureArg + 1], 'utf8')) : await liveProducts();
  const result = summarize(products);
  writeResult(result);
  process.exitCode = exitCodeFor(result);
}

if (process.argv[1] && path.resolve(process.argv[1]) === path.resolve(MODULE_PATH)) {
  main().catch(error => {
    writeResult({ skill: 'zero-price-orderable-canary', verdict: 'WARN', status: 'WARN', ts: new Date().toISOString(), zero_price_orderable: null, searched_active_unique: 0, detail: `canary error: ${error.message}` });
    process.exitCode = 1;
  });
}