← back to Dw Rotation Activator
lib/weight-gate.js
92 lines
// weight-gate.js — TK-11471 (enforcing Steve's TK-11414 rule) for dw-rotation-activator.
//
// NO product may go DRAFT->ACTIVE with a missing/zero product WEIGHT. Zero weight collapses an
// order into the lowest weight tier / free-shipping band and mis-costs DW freight on every order
// that touches the product.
//
// WHY THIS FILE EXISTS SEPARATELY rather than importing ~/Projects/designerwallcoverings/scripts/
// lib/weight-guard.mjs: same reasoning as this repo's private-label leak guard (DTD verdict B,
// 2026-07-21) — a ~/Projects app does not cross-import another app's tree. Constants are kept
// IDENTICAL to that module and to lib/weight_guard.py; weight-gate.test.js asserts that
// mechanically against the .mjs source so the two cannot drift into disagreeing about what a
// compliant weight is.
//
// SAMPLES COUNT. dw-active-weight-canary FAILs on ANY zero-weight ACTIVE variant — its live run
// on 2026-09-11 split the offenders 43 sample / 40 sellable. The .mjs's zeroWeightBlockers()
// filters samples OUT and is deprecated there; that bug is deliberately not reproduced here.
//
// FAIL-SAFE: returns {ok:false} on anything it cannot positively confirm. It can only BLOCK an
// activation, never cause one. In particular, a response that does not carry the weight FIELD is
// a failure, not a pass — otherwise a query regression that drops the field would silently
// re-open the hole with a green gate, which is the exact false-green class this ticket exists for.
const SAMPLE_WEIGHT_LB = 0.25;
const FALLBACK_LB = 2.0;
const TYPE_DEFAULT_LB = {
'Wallcovering': 3.0, 'Wallcoverings': 3.0, 'Wallpaper': 3.0,
'Metallic Wallcovering': 3.0, 'Commercial Wallcovering': 3.0,
'Mural': 4.0,
'Fabric': 1.0, 'Commercial Fabric': 1.0, 'Commercial Drapery': 1.0,
'Trim': 0.5, 'Acoustic Panel': 6.0, 'Pillow': 1.5,
'Upholstered Walls/Panels': 6.0, 'Tin Ceiling Tile': 2.0,
'Hardware': 1.0, 'Furniture': 15.0, 'Memo Sample': 0.25,
};
const norm = (t) => String(t ?? '').trim().toLowerCase();
function isSampleVariant(v = {}) {
const label = norm(v.title ?? v.option1 ?? '');
const sku = norm(v.sku);
if (label.includes('sample') || label.includes('memo')) return true;
if (sku.endsWith('-sample') || sku.includes('sample')) return true;
const p = Number(v.price);
return Number.isFinite(p) && Math.abs(p - 4.25) < 0.01;
}
// Did the response actually CARRY the weight field? Distinguishes "measured zero" from
// "never asked / field absent" — an unmeasured input is never a pass (CLAUDE.md TK-11431 am.1).
function weightFieldPresent(v = {}) {
const m = v?.inventoryItem?.measurement;
return !!(m && Object.prototype.hasOwnProperty.call(m, 'weight'));
}
function currentWeightLb(v = {}) {
const w = v?.inventoryItem?.measurement?.weight;
if (!w || w.value == null) return 0;
const val = Number(w.value);
if (!Number.isFinite(val)) return 0;
switch (norm(w.unit)) {
case 'kilograms': return val * 2.20462;
case 'grams': return val / 453.59237;
case 'ounces': return val / 16;
default: return val; // POUNDS
}
}
function defaultWeightLb(v = {}, productType) {
if (isSampleVariant(v)) return SAMPLE_WEIGHT_LB;
return TYPE_DEFAULT_LB[productType] ?? FALLBACK_LB;
}
/**
* The gate. `n` is the LIVE product node from STATUS_Q.
* @returns {{ok: boolean, reasons: string[]}}
*/
function weightGuard(n = {}) {
const variants = n?.variants?.nodes || [];
if (!variants.length) return { ok: false, reasons: ['weight: no variants on the live node — cannot assert weight>0'] };
const unmeasured = variants.filter((v) => !weightFieldPresent(v));
if (unmeasured.length) {
return { ok: false, reasons: [`weight: ${unmeasured.length}/${variants.length} variant(s) came back WITHOUT the weight field — the query is not measuring weight; refusing to pass on unmeasured data`] };
}
const zero = variants.filter((v) => !(currentWeightLb(v) > 0));
if (zero.length) {
return { ok: false, reasons: [`weight>0: ${zero.length}/${variants.length} variant(s) at zero/missing weight (${zero.map((v) => v.sku || v.title || '?').join(', ')})`] };
}
return { ok: true, reasons: [] };
}
module.exports = { weightGuard, currentWeightLb, isSampleVariant, weightFieldPresent,
defaultWeightLb, SAMPLE_WEIGHT_LB, FALLBACK_LB, TYPE_DEFAULT_LB };