diff --git a/scripts/smoke.mjs b/scripts/smoke.mjs index c54d85a..ee0c04d 100644 --- a/scripts/smoke.mjs +++ b/scripts/smoke.mjs @@ -394,7 +394,147 @@ for (const quality of QUALITIES) { }) } -// ─── 4. Summary + exit code ─────────────────────────────────────────────────── +// ─── 4. Lick + level schema validation (C-20) ───────────────────────────────── +// +// Import the REAL validator in lib mode (KB_VALIDATE_AS_LIB skips the full-KB +// run) and exercise its exported checkLick/LEVELS against in-memory fixtures: +// a good lick must pass, and bad-vocab / bad-string-range / duplicate-id licks +// must FAIL — proving the validator's lick rules actually bite. + +console.log('\nLick + level schema (validate-kb lib mode):') + +process.env.KB_VALIDATE_AS_LIB = '1' +const kbv = await load('scripts/validate-kb.mjs') +const { checkLick, LEVELS, LICK_TECHNIQUES } = kbv + +check('validate-kb exports checkLick / LEVELS / LICK_TECHNIQUES in lib mode', () => { + assert(typeof checkLick === 'function', 'checkLick is not a function') + assert(Array.isArray(LEVELS) && LEVELS.join(',') === 'foundation,intermediate', + `LEVELS must be exactly [foundation, intermediate], got ${JSON.stringify(LEVELS)}`) + assert(Array.isArray(LICK_TECHNIQUES) && LICK_TECHNIQUES.length === 8, + `expected the 8-word technique vocab, got ${JSON.stringify(LICK_TECHNIQUES)}`) + for (const t of ['hammer-on', 'pull-off', 'slide', 'bend', 'double-stop', 'ghost-note', 'chromatic-approach', 'vibrato']) + assert(LICK_TECHNIQUES.includes(t), `vocab missing '${t}'`) +}) + +// A realistic, fully-valid fixture (style-prefixed id, vocab techniques, +// strings 1–6, frets 0–15, per-note techniques present in the summary). +const goodLick = () => ({ + id: 'blues-box1-roll', + name: 'B.B. box roll', + level: 'foundation', + chordContext: 'over the I7', + techniques: ['bend', 'vibrato'], + tab: [ + { string: 2, fret: 8 }, + { string: 1, fret: 8, technique: 'bend' }, + { string: 1, fret: 10, technique: 'vibrato' }, + { string: 2, fret: 8 }, + ], +}) + +check('good in-memory lick fixture PASSES checkLick (0 errors)', () => { + const errs = checkLick('fixture', goodLick(), 'blues', new Set()) + assert(errs.length === 0, `expected clean pass, got: ${errs.join('; ')}`) +}) + +check('bad-vocab lick FAILS (technique outside the fixed vocabulary)', () => { + const lick = goodLick() + lick.techniques = ['bend', 'tapping'] // 'tapping' is not in the vocab + const errs = checkLick('fixture', lick, 'blues', new Set()) + assert(errs.length > 0, 'bad vocab was accepted') + assert(errs.some((e) => e.includes("'tapping'")), `no error names 'tapping': ${errs.join('; ')}`) +}) + +check('bad per-note technique FAILS (vocab enforced on tab notes too)', () => { + const lick = goodLick() + lick.tab[1].technique = 'sweep-picking' + const errs = checkLick('fixture', lick, 'blues', new Set()) + assert(errs.some((e) => e.includes("'sweep-picking'")), `per-note vocab not enforced: ${errs.join('; ')}`) +}) + +check('bad-string-range lick FAILS (string 7 / string 0 rejected)', () => { + for (const bad of [7, 0]) { + const lick = goodLick() + lick.tab[0].string = bad + const errs = checkLick('fixture', lick, 'blues', new Set()) + assert(errs.some((e) => e.includes('string must be an integer 1–6')), + `string ${bad} was accepted: ${errs.join('; ')}`) + } +}) + +check('bad-fret lick FAILS (fret 16 / negative / non-integer rejected)', () => { + for (const bad of [16, -1, 3.5]) { + const lick = goodLick() + lick.tab[0].fret = bad + const errs = checkLick('fixture', lick, 'blues', new Set()) + assert(errs.some((e) => e.includes('fret must be an integer')), + `fret ${bad} was accepted: ${errs.join('; ')}`) + } +}) + +check('duplicate-id lick FAILS (ids global across progressions AND licks)', () => { + const ids = new Set() + assert(checkLick('fixture', goodLick(), 'blues', ids).length === 0, 'first insert should pass') + const errs = checkLick('fixture', goodLick(), 'blues', ids) // same id again + assert(errs.some((e) => e.includes('duplicate id')), `duplicate id was accepted: ${errs.join('; ')}`) + // Colliding with an existing PROGRESSION id must also fail (shared namespace). + const progIds = new Set(['blues-box1-roll']) + const errs2 = checkLick('fixture', goodLick(), 'blues', progIds) + assert(errs2.some((e) => e.includes('duplicate id')), 'collision with a progression id was accepted') +}) + +check('wrong style prefix / bad level / empty tab all FAIL', () => { + const wrongPrefix = goodLick(); wrongPrefix.id = 'jazz-box1-roll' + assert(checkLick('fixture', wrongPrefix, 'blues', new Set()).some((e) => e.includes("starting with 'blues-'")), + 'wrong style prefix accepted') + const badLevel = goodLick(); badLevel.level = 'advanced' + assert(checkLick('fixture', badLevel, 'blues', new Set()).some((e) => e.includes('level must be one of')), + "level 'advanced' accepted") + const emptyTab = goodLick(); emptyTab.tab = [] + assert(checkLick('fixture', emptyTab, 'blues', new Set()).some((e) => e.includes('tab must be a non-empty')), + 'empty tab accepted') +}) + +check('per-note technique missing from techniques[] summary FAILS (card tags stay honest)', () => { + const lick = goodLick() + lick.tab[2].technique = 'slide' // valid vocab, but not in techniques: [bend, vibrato] + const errs = checkLick('fixture', lick, 'blues', new Set()) + assert(errs.some((e) => e.includes("must also appear in the lick's techniques[]")), + `summary consistency not enforced: ${errs.join('; ')}`) +}) + +// Progression `level` is optional in the KB — assert today's KB either omits it +// or uses a legal value (guards P-20's tagging against typos reaching main). +check("every KB progression 'level', when present, is foundation|intermediate", () => { + for (const styleName of styleNames) { + for (const p of kb[styleName]?.progressions ?? []) { + if (p.level !== undefined) { + assert(LEVELS.includes(p.level), `${styleName}/${p.id}: bad level '${p.level}'`) + } + } + } +}) + +// Same guard for any licks already shipped in the KB: run the REAL packs' +// licks (if any) through checkLick — the registry and the validator must agree. +check('every KB pack licks[] entry (if any) passes checkLick', () => { + const ids = new Set() + for (const styleName of styleNames) { + const instruments = kb[styleName]?.instruments ?? {} + for (const [inst, pack] of Object.entries(instruments)) { + if (pack?.licks === undefined) continue + assert(Array.isArray(pack.licks) && pack.licks.length, + `${styleName}/${inst}: licks, when present, must be a non-empty array`) + for (const lick of pack.licks) { + const errs = checkLick(`${styleName}/${inst} ${lick?.id ?? '?'}`, lick, styleName, ids) + assert(errs.length === 0, errs.join('; ')) + } + } + } +}) + +// ─── 5. Summary + exit code ─────────────────────────────────────────────────── const total = passed + failures.length console.log('') diff --git a/scripts/validate-kb.mjs b/scripts/validate-kb.mjs index 6a001bd..d56e8a0 100644 --- a/scripts/validate-kb.mjs +++ b/scripts/validate-kb.mjs @@ -1,5 +1,10 @@ // KB quality gate — validates src/data/kb/ against the contract in src/data/kb/SCHEMA.md. // Run: node scripts/validate-kb.mjs (exit 1 on any error) +// +// Lib mode: scripts/smoke.mjs imports this file with KB_VALIDATE_AS_LIB=1 set to +// reuse the exported pure checks (checkLick, LEVELS, LICK_TECHNIQUES) against +// in-memory fixtures — same logic, no copy. When the env var is absent the +// script runs the full KB validation as before. import { readdirSync, existsSync } from 'node:fs' import { fileURLToPath, pathToFileURL } from 'node:url' import { dirname, join } from 'node:path' @@ -16,6 +21,16 @@ const MIN_PROGRESSIONS = 4 const MIN_PLAYS = 2 const MAX_SPAN = 4 +// Optional progression/lick difficulty tags (SCHEMA.md — absent = 'foundation'). +export const LEVELS = ['foundation', 'intermediate'] +// Fixed technique vocabulary for licks — both the techniques[] summary and each +// tab note's optional technique must come from this list (SCHEMA.md). +export const LICK_TECHNIQUES = [ + 'hammer-on', 'pull-off', 'slide', 'bend', + 'double-stop', 'ghost-note', 'chromatic-approach', 'vibrato', +] +const LICK_MAX_FRET = 15 + const errors = [] const err = (where, msg) => errors.push(`${where}: ${msg}`) @@ -105,10 +120,51 @@ function checkBassPlay(where, play, prog) { }) } +// Pure lick validation (SCHEMA.md "Licks" section). Returns an array of error +// strings (already where-prefixed); mutates seenIds by adding the lick's id so +// ids stay globally unique across ALL progressions and licks (same rule as +// progression ids). Exported for reuse by scripts/smoke.mjs. +export function checkLick(where, lick, style, seenIds) { + const out = [] + const e = (msg) => out.push(`${where}: ${msg}`) + if (!lick || typeof lick !== 'object') { e('lick must be an object'); return out } + if (typeof lick.id !== 'string' || !lick.id.startsWith(`${style}-`)) + e(`id must be a string starting with '${style}-'`) + else if (seenIds.has(lick.id)) e(`duplicate id '${lick.id}' (ids are global across progressions AND licks)`) + else seenIds.add(lick.id) + if (!lick.name) e('name missing') + if (!LEVELS.includes(lick.level)) e(`level must be one of ${LEVELS.join(' | ')}, got '${lick.level}'`) + if (typeof lick.chordContext !== 'string' || !lick.chordContext) + e("chordContext missing (which chord/station the lick fits, e.g. 'dom7' or 'over the I7')") + const summary = new Set() + if (!Array.isArray(lick.techniques)) e('techniques must be an array (may be empty for a plain-picked lick)') + else for (const t of lick.techniques) { + if (!LICK_TECHNIQUES.includes(t)) e(`unknown technique '${t}' — allowed: ${LICK_TECHNIQUES.join(', ')}`) + summary.add(t) + } + if (!Array.isArray(lick.tab) || !lick.tab.length) { e('tab must be a non-empty ordered array of notes'); return out } + lick.tab.forEach((note, i) => { + const nw = `tab[${i}]` + if (!note || typeof note !== 'object') return e(`${nw} must be an object {string, fret, technique?}`) + if (!Number.isInteger(note.string) || note.string < 1 || note.string > 6) + e(`${nw} string must be an integer 1–6 (1 = high e, 6 = low E), got ${JSON.stringify(note.string)}`) + if (!Number.isInteger(note.fret) || note.fret < 0 || note.fret > LICK_MAX_FRET) + e(`${nw} fret must be an integer 0–${LICK_MAX_FRET}, got ${JSON.stringify(note.fret)}`) + if (note.technique !== undefined) { + if (!LICK_TECHNIQUES.includes(note.technique)) + e(`${nw} unknown technique '${note.technique}' — allowed: ${LICK_TECHNIQUES.join(', ')}`) + else if (!summary.has(note.technique)) + e(`${nw} technique '${note.technique}' must also appear in the lick's techniques[] summary`) + } + }) + return out +} + async function loadModule(path) { return (await import(pathToFileURL(path).href)).default } +async function main() { const styleDirs = readdirSync(KB, { withFileTypes: true }).filter(d => d.isDirectory()).map(d => d.name) if (!styleDirs.length) { console.error('No style folders in src/data/kb/'); process.exit(1) } @@ -116,7 +172,7 @@ const registry = existsSync(join(KB, 'index.js')) ? await loadModule(join(KB, 'i if (!registry) err('kb/index.js', 'registry missing') const allIds = new Set() -let totals = { styles: 0, progressions: 0, plays: 0 } +let totals = { styles: 0, progressions: 0, plays: 0, licks: 0 } for (const style of styleDirs) { const dir = join(KB, style) @@ -147,6 +203,9 @@ for (const style of styleDirs) { if (!MODES.includes(p.mode)) err(pw, `unknown mode '${p.mode}'`) if (!Array.isArray(p.songs) || !p.songs.length) err(pw, 'songs missing') if (!p.tip) err(pw, 'tip missing') + // Optional difficulty tag — absent means 'foundation' (consumer default). + if (p.level !== undefined && !LEVELS.includes(p.level)) + err(pw, `level, when present, must be one of ${LEVELS.join(' | ')} — got '${p.level}'`) } totals.styles++; totals.progressions += progs.length @@ -181,6 +240,20 @@ for (const style of styleDirs) { }) }) } + + // Optional structured licks (SCHEMA.md "Licks") — a top-level `licks` key + // on the instrument pack. Absent is fine; when present it must validate. + if (pack.licks !== undefined) { + if (!Array.isArray(pack.licks) || !pack.licks.length) { + err(iw, 'licks, when present, must be a non-empty array') + } else { + pack.licks.forEach((lick, li) => { + const lkw = `${iw} licks[${li}] "${lick?.id ?? '?'}"` + for (const m of checkLick(lkw, lick, style, allIds)) errors.push(m) + }) + totals.licks += pack.licks.length + } + } } } @@ -189,4 +262,11 @@ if (errors.length) { for (const e of errors) console.error(' ' + e) process.exit(1) } -console.log(`✓ KB valid — ${totals.styles} style(s), ${totals.progressions} progressions, ${totals.plays} plays`) +const lickNote = totals.licks ? `, ${totals.licks} licks` : '' +console.log(`✓ KB valid — ${totals.styles} style(s), ${totals.progressions} progressions, ${totals.plays} plays${lickNote}`) +} + +// Run the full validation unless imported as a library (see header comment). +// Safe-by-default: an unset env var always means "run" — the gate can't be +// skipped by a path-comparison quirk. +if (!process.env.KB_VALIDATE_AS_LIB) await main() diff --git a/src/data/kb/SCHEMA.md b/src/data/kb/SCHEMA.md index 9a8ae84..560d0fd 100644 --- a/src/data/kb/SCHEMA.md +++ b/src/data/kb/SCHEMA.md @@ -46,12 +46,20 @@ export default [ mode: 'major', // major | minor | dorian | mixolydian | … songs: ['Autumn Leaves'], tip: 'One transferable idea.', + level: 'intermediate', // OPTIONAL: 'foundation' | 'intermediate' }, ] ``` 4–8 progressions per style. Cross-check `docs/progression-repertoire.md` §1. +**`level` (optional).** Tags the progression's difficulty for the in-app level +filter. Allowed values: `'foundation'` (the style's bread-and-butter loops) or +`'intermediate'` (secondary dominants, chained ii–Vs, backdoor, borrowed +chords…). **Omitting the field is fine and means `'foundation'`** — consumers +treat an absent `level` as foundation, so existing styles need no edits. The +validator only checks the value when the field is present. + ## Instrument packs Common envelope: @@ -126,6 +134,76 @@ Voicings are degree recipes resolved through the chord quality. Degrees: `'1' '3 } ``` +## Licks (optional, guitar first) + +A style's instrument pack may also teach short, named licks — the ordered-note +phrases the Licks & Techniques cards render. **The whole section is optional**: +a pack without licks is complete and valid. + +**How to register licks:** add a `licks` array as one more top-level key on the +instrument pack's default export (next to `styleIntro`/`comping`/`plays`/`improv` +in e.g. `src/data/kb/blues/guitar.js`). No change to `src/data/kb/index.js` is +needed — the registry already exposes the whole pack, and the UI reads +`kb[style].instruments.guitar.licks ?? []`. + +```js +export default { + styleIntro: '…', + comping: [ /* … */ ], + plays: { /* … */ }, + improv: { /* … */ }, + + // OPTIONAL — structured licks (this section): + licks: [ + { + id: 'blues-box1-bb-answer', // '