// Smoke-test harness — asserts the KB registry and the loop matcher are wired // correctly before a merge. Imports the REAL src/data/kb/index.js and // src/lib/match.js (no mocks), runs structural + behavioural checks, and exits // non-zero on any failure so CI can gate on it. // // Run: node scripts/smoke.mjs (exit 1 on any failure, 0 on all-pass) // // Style mirrors scripts/validate-kb.mjs: plain Node ESM, node: imports, a flat // list of checks with ✓/✗ per check, a summary line, and process.exit(). import { register } from 'node:module' import { fileURLToPath, pathToFileURL } from 'node:url' import { dirname, join } from 'node:path' // match.js imports './theory' extensionless (resolved by Vite at build time, but // raw Node ESM requires the extension). Register a tiny resolve hook that retries // a failed extensionless relative specifier with '.js' appended, so we can import // the REAL match.js unmodified (it's locked to task L-01 — we must not touch it). register( 'data:text/javascript,' + encodeURIComponent(` export async function resolve(specifier, context, next) { try { return await next(specifier, context) } catch (e) { if (/^\\.{1,2}\\//.test(specifier) && !/\\.[mc]?js$/.test(specifier)) { return await next(specifier + '.js', context) } throw e } } `), import.meta.url, ) const ROOT = join(dirname(fileURLToPath(import.meta.url)), '..') const load = async (rel) => (await import(pathToFileURL(join(ROOT, rel)).href)) // ─── Tiny assertion harness ─────────────────────────────────────────────────── let passed = 0 const failures = [] function check(label, fn) { try { fn() passed++ console.log(` ✓ ${label}`) } catch (e) { failures.push(`${label}: ${e.message}`) console.log(` ✗ ${label} — ${e.message}`) } } function warn(label, msg) { console.log(` ⚠ ${label} — ${msg} (skipped)`) } function assert(cond, msg) { if (!cond) throw new Error(msg) } // ─── Load the real modules ──────────────────────────────────────────────────── const kb = (await load('src/data/kb/index.js')).default const match = await load('src/lib/match.js') const { buildLoopIndex, matchLoopToProgression, findLoopPosition } = match const theory = await load('src/lib/theory.js') const { CHORD_TYPES } = theory const piano = await load('src/lib/piano.js') const { pianoVoicing, pianoVoicingChain, voicingToneSet, hasTrueSeventh } = piano // ─── 1. Registry integrity ──────────────────────────────────────────────────── console.log('\nRegistry integrity:') check('KB default export is a non-empty object with ≥1 style', () => { assert(kb && typeof kb === 'object', 'kb default export is not an object') assert(Object.keys(kb).length >= 1, 'kb has no styles') }) const styleNames = kb && typeof kb === 'object' ? Object.keys(kb) : [] const allIds = new Map() // id → style (for uniqueness across the whole KB) for (const styleName of styleNames) { const style = kb[styleName] check(`style '${styleName}' has meta, progressions[], instruments.guitar`, () => { assert(style && typeof style === 'object', 'style entry is not an object') assert(style.meta && typeof style.meta === 'object', 'missing meta') assert(Array.isArray(style.progressions) && style.progressions.length > 0, 'progressions must be a non-empty array') assert(style.instruments && typeof style.instruments === 'object', 'missing instruments') assert(style.instruments.guitar && typeof style.instruments.guitar === 'object', 'missing instruments.guitar') }) const progs = Array.isArray(style?.progressions) ? style.progressions : [] for (const p of progs) { check(`'${styleName}' progression '${p?.id ?? '?'}' has consistent degrees/qualities/rn/bars + unique id`, () => { assert(typeof p.id === 'string' && p.id.length > 0, 'progression id missing') assert(!allIds.has(p.id), `duplicate id '${p.id}' (also in style '${allIds.get(p.id)}')`) allIds.set(p.id, styleName) const n = Array.isArray(p.degrees) ? p.degrees.length : 0 assert(n > 0, 'degrees missing/empty') for (const [field, arr] of [['qualities', p.qualities], ['rn', p.rn], ['bars', p.bars]]) { assert(Array.isArray(arr), `${field} is not an array`) assert(arr.length === n, `${field} length ${arr.length} ≠ degrees length ${n}`) } }) } } // ─── 2. Matcher correctness on sample loops ─────────────────────────────────── console.log('\nMatcher correctness:') const index = buildLoopIndex(kb) check('buildLoopIndex returns a { byCanonical: Map } shape', () => { assert(index && index.byCanonical instanceof Map, 'byCanonical is not a Map') assert(index.byCanonical.size > 0, 'index is empty') }) // Helper: rotate a degree array by r so it starts at index r. const rotate = (arr, r) => arr.map((_, i) => arr[(r + i) % arr.length]) // Helper: re-base a degree array so its first element is 0 (the loop "shape"). const rebase = (arr) => { const base = arr[0] return arr.map((d) => (((d - base) % 12) + 12) % 12) } // --- ii–V–I → some jazz 2-5-1, matched:true; derive the expected id from KB --- // (don't hardcode 'jazz-251-major' — look up what the loop actually maps to and // assert its structural truth: the matched progression's degrees rotate to the // loop's degree shape.) { const loop = ['Dm7', 'G7', 'Cmaj7'] // ii–V–I in C → degree shape [0,5,10] const r = matchLoopToProgression(loop, index) check('ii–V–I [Dm7,G7,Cmaj7] matches a progression (matched:true)', () => { assert(r.matched === true, `expected matched:true, got ${JSON.stringify(r)}`) assert(typeof r.id === 'string' && r.id.length > 0, 'matched but no id') assert(kb[r.style], `matched style '${r.style}' not in KB`) }) check("ii–V–I maps to a 2-5-1-shaped progression (derived from KB)", () => { // Structural: the matched progression's degrees, rotated by the reported // rotation to align with the loop, must equal the loop's own degree shape. const loopShape = rebase([2, 7, 0]) // Dm7 G7 Cmaj7 pcs → re-based shape [0,5,10] const kbDeg = r.progression.degrees const rotatedKbShape = rebase(rotate(kbDeg, r.rotation)) assert(rotatedKbShape.join(',') === loopShape.join(','), `KB degrees ${kbDeg} rotated by ${r.rotation} → ${rotatedKbShape} ≠ loop shape ${loopShape}`) // Soft: this loop is a major 2-5-1, expect the major-quality (maj7) candidate. assert(/251/.test(r.id) || r.progression.qualities.includes('maj7'), `expected a 2-5-1-shaped id, got '${r.id}'`) }) // Remember the id for the rotation test below (derived, not hardcoded). globalThis.__ii_v_i_id = r.id } // --- A rotation of ii–V–I matches the SAME id with non-zero rotation ---------- { const loop = ['G7', 'Cmaj7', 'Dm7'] // V–I–ii: same loop, rotated by 1 const r = matchLoopToProgression(loop, index) check('rotation [G7,Cmaj7,Dm7] matches the SAME id as ii–V–I', () => { assert(r.matched === true, `expected matched:true, got ${JSON.stringify(r)}`) assert(r.id === globalThis.__ii_v_i_id, `rotation matched '${r.id}', expected same id '${globalThis.__ii_v_i_id}'`) }) check('rotation reports a non-zero rotation index', () => { assert(r.rotation !== 0, `expected non-zero rotation, got ${r.rotation}`) assert(r.rotation > 0 && r.rotation < loop.length, `rotation ${r.rotation} out of range`) // The rotation index should point at the loop slot aligning with KB degrees[0]. // KB ii–V–I starts on ii (Dm7); Dm7 is at loop index 2 here. assert(loop[r.rotation] === 'Dm7', `loop[${r.rotation}] is '${loop[r.rotation]}', expected 'Dm7'`) }) } // --- I–V–vi–IV axis loop → axis progression (pop may be absent) ---------------- { const loop = ['C', 'G', 'Am', 'F'] // I–V–vi–IV, degree shape [0,7,9,5] const r = matchLoopToProgression(loop, index) const loopShape = rebase([0, 7, 9, 5]) if (r.matched) { check('axis [C,G,Am,F] matches an axis-shaped progression', () => { assert(kb[r.style], `matched style '${r.style}' not in KB`) // Whatever style owns the axis, its degrees must rotate to the loop shape. const rotatedKbShape = rebase(rotate(r.progression.degrees, r.rotation)) assert(rotatedKbShape.join(',') === loopShape.join(','), `matched '${r.id}' degrees ${r.progression.degrees} rotated by ${r.rotation} don't match axis shape ${loopShape}`) // The axis is the four-chord I–V–vi–IV; sanity-check it's a 4-chord loop. assert(r.progression.degrees.length === 4, `expected a 4-chord axis, got length ${r.progression.degrees.length}`) }) } else { warn('axis [C,G,Am,F] match', 'no axis-shaped 4-chord progression in current KB') } } // --- Chromatic / garbage loop → matched:false, no throw ----------------------- { check('chromatic loop [C,C#,D] returns matched:false cleanly (no throw)', () => { const r = matchLoopToProgression(['C', 'C#', 'D'], index) assert(r && r.matched === false, `expected matched:false, got ${JSON.stringify(r)}`) assert(r.id === null, `expected id:null on no-match, got ${r.id}`) }) } // --- findLoopPosition: in-range index for a known loop, -1 for no-match -------- { const loop = ['Dm7', 'G7', 'Cmaj7'] check('findLoopPosition returns an in-range index for a known loop', () => { const history = ['Dm7', 'G7'] // player is on G7 const pos = findLoopPosition(history, loop) assert(Number.isInteger(pos), `expected an integer, got ${pos}`) assert(pos >= 0 && pos < loop.length, `index ${pos} out of range [0,${loop.length})`) assert(loop[pos] === 'G7', `loop[${pos}] is '${loop[pos]}', expected 'G7'`) }) check('findLoopPosition returns -1 when the last chord is not in the loop', () => { const pos = findLoopPosition(['Ebmaj7'], loop) assert(pos === -1, `expected -1, got ${pos}`) }) check('findLoopPosition returns -1 on empty inputs', () => { assert(findLoopPosition([], loop) === -1, 'empty history should be -1') assert(findLoopPosition(['Dm7'], []) === -1, 'empty loop should be -1') }) } // ─── 3. Piano voicing resolver (C-10) ───────────────────────────────────────── // // Exercise src/lib/piano.js across every CHORD_TYPES quality (all 14) at a // couple of roots, for each forced style + the default. Expected facts are // derived from the resolver/theory (voicingToneSet / hasTrueSeventh / the chord // intervals), never hardcoded as brittle note arrays. console.log('\nPiano resolver:') const QUALITIES = Object.keys(CHORD_TYPES) // all 14 const PIANO_ROOTS = [0, 7] // C and G const PIANO_STYLES = ['root', 'shell', 'rootlessA', 'rootlessB', undefined] // undefined = default const mod12 = (n) => (((n % 12) + 12) % 12) const styleName = (s) => s ?? 'default' check(`CHORD_TYPES exposes all 14 qualities for the piano sweep`, () => { assert(QUALITIES.length === 14, `expected 14 CHORD_TYPES, got ${QUALITIES.length}`) }) // --- 3a. Per (quality × root × style): valid shape, no wrong notes, 3rd & 7th -- for (const quality of QUALITIES) { const ints = CHORD_TYPES[quality].intervals const hasReal3rd = ints.some((i) => i === 3 || i === 4) // The "real 3rd" target pc: minor/major 3rd if present, else the suspension // tone (sus2 → 2, sus4 → 5) that stands in the 3rd slot. const susTone = ints[1] // sus2 → 2, sus4 → 5 (index-1 stand-in for the 3rd) const trueSeventh = hasTrueSeventh(quality) // interval 10 or 11 present for (const rootPc of PIANO_ROOTS) { // expected 3rd-slot pc (absolute pc) const third3 = mod12(rootPc + 3) const third4 = mod12(rootPc + 4) const susPc = mod12(rootPc + susTone) const seventhInt = ints.find((i) => i === 10 || i === 11) const seventhPc = seventhInt === undefined ? null : mod12(rootPc + seventhInt) for (const style of PIANO_STYLES) { const chord = { rootPc, quality } const opts = style === undefined ? {} : { style } const tag = `${quality}@${rootPc} [${styleName(style)}]` check(`${tag}: valid voicing shape`, () => { const v = pianoVoicing(chord, opts) assert(v && typeof v === 'object', 'no voicing object') assert(Array.isArray(v.notes) && v.notes.length > 0, 'notes must be a non-empty array') assert(v.notes.every((n) => Number.isFinite(n)), 'notes must all be numbers') assert(Array.isArray(v.pcs) && v.pcs.length > 0, 'pcs must be a non-empty array') assert(typeof v.bass === 'number', 'bass must be a number') assert(typeof v.style === 'string' && v.style.length > 0, 'style must be a non-empty string') assert(typeof v.label === 'string' && v.label.length > 0, 'label must be a non-empty string') }) check(`${tag}: no wrong notes (pcs ⊆ voicingToneSet)`, () => { const v = pianoVoicing(chord, opts) // Scope the legal-tone set to the voicing's ACTUAL produced style. const legal = voicingToneSet({ rootPc, quality }, v.style) for (const pc of v.pcs) { assert(legal.has(pc), `pc ${pc} not in voicingToneSet(${quality}, ${v.style}) {${[...legal].sort((a, b) => a - b)}}`) } }) check(`${tag}: real 3rd (or suspension) present`, () => { const v = pianoVoicing(chord, opts) if (hasReal3rd) { assert(v.pcs.includes(third3) || v.pcs.includes(third4), `expected a 3rd (pc ${third3} or ${third4}) in pcs {${v.pcs}}`) } else { // sus2/sus4 — the 2 or 4 stands in the 3rd slot. assert(v.pcs.includes(susPc), `expected suspension tone pc ${susPc} in pcs {${v.pcs}}`) } }) check(`${tag}: true 7th present in shell/rootless`, () => { const v = pianoVoicing(chord, opts) const isSeventhVoicing = v.style === 'shell' || v.style === 'rootlessA' || v.style === 'rootlessB' if (trueSeventh && isSeventhVoicing) { assert(v.pcs.includes(seventhPc), `${v.style} of a true-7th chord must include the 7th pc ${seventhPc}, got {${v.pcs}}`) } }) check(`${tag}: notes within [0,36] and bass == min(notes)`, () => { const v = pianoVoicing(chord, opts) assert(v.notes.every((n) => n >= 0 && n <= 36), `notes ${JSON.stringify(v.notes)} out of [0,36]`) assert(v.bass === Math.min(...v.notes), `bass ${v.bass} ≠ min(notes) ${Math.min(...v.notes)}`) }) } } } // --- 3b. No duplicate absolute note within a single voicing (sus2 nit watch) --- // Sweep every quality × style (forced + default). A duplicate absolute note in // one voicing's `notes` is a defect surfaced as a ✗ (L-10 gate flagged a // forced-rootless sus2 collision). If the resolver was since deduped, this // passes. We report WHICH quality/style collides so it's visible. { const dupes = [] for (const quality of QUALITIES) { for (const rootPc of PIANO_ROOTS) { for (const style of PIANO_STYLES) { const opts = style === undefined ? {} : { style } const v = pianoVoicing({ rootPc, quality }, opts) if (v.notes.length !== new Set(v.notes).size) { dupes.push(`${quality}@${rootPc} [${styleName(style)}] notes=${JSON.stringify(v.notes)}`) } } } } check('no duplicate absolute note within any single voicing (all qualities × styles)', () => { assert(dupes.length === 0, `duplicate-note voicing(s) found: ${dupes.join('; ')}`) }) } // --- 3c. Determinism: same input → identical notes ----------------------------- { check('pianoVoicing is deterministic (same input → identical notes)', () => { for (const quality of QUALITIES) { for (const rootPc of PIANO_ROOTS) { for (const style of PIANO_STYLES) { const opts = style === undefined ? {} : { style } const a = pianoVoicing({ rootPc, quality }, opts) const b = pianoVoicing({ rootPc, quality }, opts) assert(a.notes.join(',') === b.notes.join(','), `non-deterministic notes for ${quality}@${rootPc} [${styleName(style)}]: ${a.notes} vs ${b.notes}`) } } } }) } // --- 3d. Voice-leading sanity: chain pcs == per-chord unthreaded pcs ------------ // Threading re-registers (slides whole shapes by octaves) but never changes the // pitch CONTENT — each chained voicing's pcs must equal the same chord voiced // alone (default style), as a set. { const chords = [ { rootPc: 2, quality: 'min7' }, // Dm7 { rootPc: 7, quality: 'dom7' }, // G7 { rootPc: 0, quality: 'maj7' }, // Cmaj7 ] const chain = pianoVoicingChain(chords) const pcSet = (arr) => [...new Set(arr)].sort((a, b) => a - b).join(',') check('pianoVoicingChain(ii–V–I) returns 3 voicings', () => { assert(Array.isArray(chain) && chain.length === 3, `expected 3 voicings, got ${Array.isArray(chain) ? chain.length : typeof chain}`) }) check('chain pcs match the per-chord unthreaded pcs (threading never changes pitch content)', () => { chords.forEach((chord, i) => { const solo = pianoVoicing(chord) // same default style, no prev assert(pcSet(chain[i].pcs) === pcSet(solo.pcs), `chord ${i} (${chord.quality}@${chord.rootPc}): chain pcs {${pcSet(chain[i].pcs)}} ≠ unthreaded {${pcSet(solo.pcs)}}`) }) }) } // ─── 4. Summary + exit code ─────────────────────────────────────────────────── const total = passed + failures.length console.log('') if (failures.length) { console.error(`✗ Smoke test FAILED — ${failures.length}/${total} check(s) failed:\n`) for (const f of failures) console.error(' ' + f) process.exit(1) } console.log(`✓ Smoke test passed — ${passed}/${total} checks green (${styleNames.length} styles, ${allIds.size} progressions)`) process.exit(0)