← 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
M content-match-gen.mjsA match-helpers.mjsA test/match-helpers.test.mjs
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 →