[object Object]

← back to Dw Sku Integrity

TK-10900: extract pure match helpers into tested module (47->61 tests, 0 regression)

0f0dacf0aedcf202bbf72200ad612df495ebd335 · 2026-08-31 04:25:09 -0700 · codex-10896

content-match-gen.mjs can't be unit-tested (DB queries at import). Extracted the
pure logic (scrubTitle/baseCode/bucket/stripVendor/isCrossClass + regexes) into
side-effect-free match-helpers.mjs (mirrors classify.mjs), imported by the generator.
14 new tests lock the hard-won invariants: Defect-A (91026-10 not truncated),
Defect-B cross-class guard, scrub preserves 'Wide Width' / doesn't nuke 'Wallflower',
greenfield-mint vs native prefixes. Carnegie regen unchanged (2,745).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

Files touched

Diff

commit 0f0dacf0aedcf202bbf72200ad612df495ebd335
Author: codex-10896 <steve@designerwallcoverings.com>
Date:   Mon Aug 31 04:25:09 2026 -0700

    TK-10900: extract pure match helpers into tested module (47->61 tests, 0 regression)
    
    content-match-gen.mjs can't be unit-tested (DB queries at import). Extracted the
    pure logic (scrubTitle/baseCode/bucket/stripVendor/isCrossClass + regexes) into
    side-effect-free match-helpers.mjs (mirrors classify.mjs), imported by the generator.
    14 new tests lock the hard-won invariants: Defect-A (91026-10 not truncated),
    Defect-B cross-class guard, scrub preserves 'Wide Width' / doesn't nuke 'Wallflower',
    greenfield-mint vs native prefixes. Carnegie regen unchanged (2,745).
    
    Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
---
 content-match-gen.mjs       | 35 ++---------------
 match-helpers.mjs           | 46 ++++++++++++++++++++++
 test/match-helpers.test.mjs | 95 +++++++++++++++++++++++++++++++++++++++++++++
 3 files changed, 144 insertions(+), 32 deletions(-)

diff --git a/content-match-gen.mjs b/content-match-gen.mjs
index 7f2f332..e380847 100644
--- a/content-match-gen.mjs
+++ b/content-match-gen.mjs
@@ -29,6 +29,7 @@ import { execFileSync } from 'node:child_process';
 import { readFileSync, writeFileSync, mkdirSync, existsSync, rmSync } from 'node:fs';
 import { join, dirname } from 'node:path';
 import { fileURLToPath } from 'node:url';
+import { CODE_SHAPE, GREENFIELD_MINT, MINT_CATALOG, norm, scrubTitle, baseCode, bucket, stripVendor, isCrossClass } from './match-helpers.mjs';
 
 const HERE = dirname(fileURLToPath(import.meta.url));
 const US = '\x1f', RS = '\x1e';
@@ -38,37 +39,7 @@ const CATALOG = arg('--catalog', null);
 const KAM = (arg('--kam', 'ssh root@45.61.58.125 psql dw_unified')).split(/\s+/);
 const LEDGER = arg('--ledger', '/tmp/_ledcodes.txt');
 const OUT = arg('--out', join(HERE, 'apply-plans-content-match'));
-const CODE_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,39}$/;
-const GREENFIELD_MINT = /^DW(AG|AX|CX|ST|SC|DX|WG)/i;
-const MINT_CATALOG = new Set(['carnegie', 'maharam', 'cmo paris', 'cmo_paris', 'stout', 'stout textiles']);
-
-const sqlEscape = (v) => String(v).replace(/'/g, "''");
-const norm = (s) => (s || '').trim().toLowerCase().replace(/\s+/g, ' ');
-// Identity-key scrub applied to BOTH the catalog key and the vendor-stripped Shopify title, so
-// storefront-only title noise ("... , <color> Wallcoverings") matches the catalog's pattern+color.
-// Removes standalone "wallcovering(s)" and treats commas as spaces. Does NOT strip meaningful pattern
-// words (e.g. "Wide Width" is part of Thibaut's pattern_name and is preserved).
-const scrubTitle = (s) => norm(s).replace(/\bwallcoverings?\b/g, ' ').replace(/,/g, ' ').replace(/\s+/g, ' ').trim();
-// Base code = strip trailing DW type-suffix groups ('-panels', '-panels-museums', '-dividers', ...).
-// Strips ONLY trailing '-<alpha>' tokens so a genuinely hyphenated real code with a numeric segment
-// ('91026-10') is preserved rather than truncated. Only applied to mint-catalog mfr_sku (dw_sku verbatim).
-const baseCode = (c) => (c || '').trim().replace(/(-[A-Za-z][A-Za-z]*)+$/, '').toUpperCase();
-// product_type semantic bucket so Shopify 'Upholstery' matches catalog 'Upholstery'/'Fabric' etc.
-const bucket = (pt) => {
-  const s = norm(pt);
-  if (/wallcover|wallpaper|mural|panel|museum|window|privacy|imo/.test(s)) return 'wall';
-  if (/upholst|fabric|textile|drapery|seat/.test(s)) return 'fabric';
-  return 'other';
-};
-// Strip the vendor name off EITHER end of a Shopify title: leading "Vendor ..." OR trailing "... | Vendor"
-// / "... - Vendor". Handles Carnegie (prefix) and Maharam (" | Maharam" suffix).
-function stripVendor(title, vendor) {
-  let t = norm(title); const v = norm(vendor);
-  const esc = v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
-  t = t.replace(new RegExp('\\s*[|\\-–]\\s*' + esc + '\\s*$', 'i'), ''); // trailing " | Vendor" / " - Vendor"
-  if (t.startsWith(v + ' ')) t = t.slice(v.length + 1);                  // leading "Vendor "
-  return t.trim();
-}
+const sqlEscape = (v) => String(v).replace(/'/g, "''"); // SQL-literal escape (tool-specific; stays here)
 
 function runPsql(cmd, sql) {
   const out = execFileSync(cmd[0], [...cmd.slice(1), '-tA', '-F', US, '-R', RS], { input: sql, maxBuffer: 1 << 30, encoding: 'utf8' });
@@ -144,7 +115,7 @@ for (const [sid, title, ptype] of shopRows) {
   // Bucket-compatibility precondition (single-candidate keys too): never write wall onto fabric / vice versa.
   const shopBucket = bucket(ptype);
   const catBuckets = codes.get(cand[0]);
-  if (shopBucket !== 'other' && !catBuckets.has(shopBucket) && !catBuckets.has('other')) {
+  if (isCrossClass(shopBucket, catBuckets)) {
     stat.cross_class++;
     review.push({ shopify_id: sid, title, key, candidate: cand[0], catalog_buckets: [...catBuckets], shopify_bucket: shopBucket, reason: 'cross_class_mismatch' });
     continue;
diff --git a/match-helpers.mjs b/match-helpers.mjs
new file mode 100644
index 0000000..42a129f
--- /dev/null
+++ b/match-helpers.mjs
@@ -0,0 +1,46 @@
+// match-helpers.mjs — PURE, side-effect-free helpers for the content-match recovery matcher.
+// Extracted from content-match-gen.mjs so they can be unit-tested in isolation (the generator itself
+// runs DB queries at import time and cannot be imported by a test). Mirrors classify.mjs's pure design.
+// NO imports, NO I/O, NO global state. TK-10900.
+
+export const CODE_SHAPE = /^[A-Za-z0-9][A-Za-z0-9._/-]{0,39}$/;
+export const GREENFIELD_MINT = /^DW(AG|AX|CX|ST|SC|DX|WG)/i;
+// Vendors whose catalog dw_sku is reverted greenfield-mint residue -> recover from mfr_sku instead.
+export const MINT_CATALOG = new Set(['carnegie', 'maharam', 'cmo paris', 'cmo_paris', 'stout', 'stout textiles']);
+
+export const norm = (s) => (s || '').trim().toLowerCase().replace(/\s+/g, ' ');
+
+// Identity-key scrub applied to BOTH the catalog key and the vendor-stripped Shopify title, so
+// storefront-only title noise ("... , <color> Wallcoverings") matches the catalog's pattern+color.
+// Removes standalone "wallcovering(s)" and treats commas as spaces. Does NOT strip meaningful pattern
+// words (e.g. "Wide Width" is part of Thibaut's pattern_name and is preserved).
+export const scrubTitle = (s) => norm(s).replace(/\bwallcoverings?\b/g, ' ').replace(/,/g, ' ').replace(/\s+/g, ' ').trim();
+
+// Base code = strip trailing DW type-suffix groups ('-panels', '-panels-museums', '-dividers', ...).
+// Strips ONLY trailing '-<alpha>' tokens so a genuinely hyphenated real code with a numeric segment
+// ('91026-10') is preserved rather than truncated. Only applied to mint-catalog mfr_sku (dw_sku verbatim).
+export const baseCode = (c) => (c || '').trim().replace(/(-[A-Za-z][A-Za-z]*)+$/, '').toUpperCase();
+
+// product_type semantic bucket so Shopify 'Upholstery' matches catalog 'Upholstery'/'Fabric' etc.
+export const bucket = (pt) => {
+  const s = norm(pt);
+  if (/wallcover|wallpaper|mural|panel|museum|window|privacy|imo/.test(s)) return 'wall';
+  if (/upholst|fabric|textile|drapery|seat/.test(s)) return 'fabric';
+  return 'other';
+};
+
+// Strip the vendor name off EITHER end of a Shopify title: leading "Vendor ..." OR trailing "... | Vendor"
+// / "... - Vendor". Handles Carnegie (prefix) and Maharam (" | Maharam" suffix).
+export function stripVendor(title, vendor) {
+  let t = norm(title); const v = norm(vendor);
+  const esc = v.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
+  t = t.replace(new RegExp('\\s*[|\\-–]\\s*' + esc + '\\s*$', 'i'), ''); // trailing " | Vendor" / " - Vendor"
+  if (t.startsWith(v + ' ')) t = t.slice(v.length + 1);                  // leading "Vendor "
+  return t.trim();
+}
+
+// True when the Shopify row's bucket is incompatible with the sole catalog candidate's buckets
+// (the Abbey-61 cross-class guard). 'other' on either side is permissive.
+export function isCrossClass(shopBucket, catBuckets) {
+  return shopBucket !== 'other' && !catBuckets.has(shopBucket) && !catBuckets.has('other');
+}
diff --git a/test/match-helpers.test.mjs b/test/match-helpers.test.mjs
new file mode 100644
index 0000000..16a2916
--- /dev/null
+++ b/test/match-helpers.test.mjs
@@ -0,0 +1,95 @@
+// Unit tests for the pure content-match helpers (TK-10900). node --test.
+import { test } from 'node:test';
+import assert from 'node:assert/strict';
+import { CODE_SHAPE, GREENFIELD_MINT, MINT_CATALOG, norm, scrubTitle, baseCode, bucket, stripVendor, isCrossClass } from '../match-helpers.mjs';
+
+test('norm: lowercases, trims, collapses whitespace', () => {
+  assert.equal(norm('  Xorel   Flux  '), 'xorel flux');
+  assert.equal(norm(null), '');
+  assert.equal(norm(undefined), '');
+});
+
+test('scrubTitle: strips standalone Wallcovering(s) and treats commas as spaces', () => {
+  // The Thibaut case that was 0-matching before the scrub.
+  assert.equal(scrubTitle('Woolston Wide Width, Spa Blue Wallcoverings'), 'woolston wide width spa blue');
+  assert.equal(scrubTitle('Aiko, Champagne Wallcoverings'), 'aiko champagne');
+});
+
+test('scrubTitle: preserves meaningful pattern words like "Wide Width"', () => {
+  // "Wide Width" is part of Thibaut's pattern_name and must NOT be stripped.
+  assert.ok(scrubTitle('Woolston Wide Width, Spa Blue Wallcoverings').includes('wide width'));
+});
+
+test('scrubTitle: does not strip "wall" embedded in another word', () => {
+  // \b guards against nuking a pattern that merely contains the substring.
+  assert.equal(scrubTitle('Wallflower Rose'), 'wallflower rose');
+});
+
+test('baseCode: strips trailing -<alpha> type suffixes to the real code', () => {
+  assert.equal(baseCode('655788-panels-museums'), '655788');
+  assert.equal(baseCode('6603S36-upholstery'), '6603S36');
+  assert.equal(baseCode('101545417-dividers'), '101545417');
+  assert.equal(baseCode('6557W94-wallcoverings'), '6557W94');
+});
+
+test('baseCode: PRESERVES a numeric-hyphenated real code (no truncation)', () => {
+  // The contrarian Defect-A guard: 91026-10's -10 is numeric, must survive.
+  assert.equal(baseCode('91026-10'), '91026-10');
+  assert.equal(baseCode('W7899-2'), 'W7899-2');
+});
+
+test('bucket: classifies wall vs fabric vs other', () => {
+  assert.equal(bucket('Upholstered Walls/Panels'), 'wall'); // the Abbey-61 class: 'panel' -> wall
+  assert.equal(bucket('Wallcoverings'), 'wall');
+  assert.equal(bucket('Museum Display Cases'), 'wall');
+  assert.equal(bucket('Upholstery'), 'fabric');
+  assert.equal(bucket('Fabric'), 'fabric');
+  assert.equal(bucket(''), 'other');
+  assert.equal(bucket('Miscellaneous'), 'other');
+});
+
+test('stripVendor: removes a leading vendor prefix (Carnegie)', () => {
+  assert.equal(stripVendor('Carnegie Xorel Flux 88', 'Carnegie'), 'xorel flux 88');
+});
+
+test('stripVendor: removes a trailing " | Vendor" suffix (Maharam)', () => {
+  assert.equal(stripVendor('Filigree Dashboard | Maharam', 'Maharam'), 'filigree dashboard');
+  assert.equal(stripVendor('Vandalia Irondale Wallcovering | Vahallan', 'Vahallan'), 'vandalia irondale wallcovering');
+});
+
+test('stripVendor: leaves a title without the vendor name unchanged (aside from norm)', () => {
+  assert.equal(stripVendor('Some Pattern 12', 'Carnegie'), 'some pattern 12');
+});
+
+test('isCrossClass: blocks wall-onto-fabric (the Abbey-61 guard) but allows compatible/other', () => {
+  assert.equal(isCrossClass('fabric', new Set(['wall'])), true);   // Abbey 61: Upholstery row vs wall-only code
+  assert.equal(isCrossClass('wall', new Set(['fabric'])), true);
+  assert.equal(isCrossClass('fabric', new Set(['fabric'])), false);
+  assert.equal(isCrossClass('fabric', new Set(['wall', 'fabric'])), false); // has a compatible bucket
+  assert.equal(isCrossClass('other', new Set(['wall'])), false);   // Shopify 'other' -> permissive
+  assert.equal(isCrossClass('fabric', new Set(['other'])), false); // catalog 'other' -> permissive
+});
+
+test('GREENFIELD_MINT: matches greenfield prefixes, not native ones', () => {
+  assert.ok(GREENFIELD_MINT.test('DWAG-380886'));
+  assert.ok(GREENFIELD_MINT.test('DWWG-100001'));
+  assert.ok(!GREENFIELD_MINT.test('DWCH-510622')); // China Seas native
+  assert.ok(!GREENFIELD_MINT.test('DWKN-250130')); // Knoll (mixed-use, handled by ledger not this regex)
+  assert.ok(!GREENFIELD_MINT.test('655790'));      // Carnegie real code
+});
+
+test('CODE_SHAPE: accepts real codes, rejects garbage/titles', () => {
+  assert.ok(CODE_SHAPE.test('655790'));
+  assert.ok(CODE_SHAPE.test('DWTT-74442'));
+  assert.ok(CODE_SHAPE.test('W7899-2'));
+  assert.ok(!CODE_SHAPE.test('a title with spaces'));
+  assert.ok(!CODE_SHAPE.test(''));
+});
+
+test('MINT_CATALOG: flags the mint-catalog vendors (use mfr_sku not dw_sku)', () => {
+  assert.ok(MINT_CATALOG.has('carnegie'));
+  assert.ok(MINT_CATALOG.has('maharam'));
+  assert.ok(MINT_CATALOG.has('stout textiles'));
+  assert.ok(!MINT_CATALOG.has('knoll'));
+  assert.ok(!MINT_CATALOG.has('china seas'));
+});

← dd4ed45 TK-10900/A3: scrub also recovers Vahallan 384 (title-mode) +  ·  back to Dw Sku Integrity  ·  TK-10900: contrarian-2 fix — guard title-mode cross-class ho 0d133ca →