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