From 4c85a2c22bb2fe96ecaa8f25fa46f071764d2ed1 Mon Sep 17 00:00:00 2001 From: Kjell Tore Guttormsen Date: Thu, 14 May 2026 21:36:10 +0200 Subject: [PATCH] fix(voyage): coerce brief_version to string + quote template + update doc pin (closes #8 #11) v5.1.0 shipped with an unquoted brief_version: 2.1 in trekbrief-template.md. parseScalar coerced it to Number 2.1, and the sequencing gate guarded on typeof === 'string', silently bypassing BRIEF_V51_MISSING_SIGNALS. Three-part atomic fix: - brief-validator.mjs:87+149 now accepts both string and number forms via String(fm.brief_version) coercion. - trekbrief-template.md quotes the value so new briefs parse as String. - doc-consistency.test.mjs pins the QUOTED form going forward. Three regression tests added in brief-validator.test.mjs. --- .../voyage/lib/validators/brief-validator.mjs | 12 ++++--- .../voyage/templates/trekbrief-template.md | 2 +- .../voyage/tests/lib/doc-consistency.test.mjs | 6 ++-- .../tests/validators/brief-validator.test.mjs | 32 +++++++++++++++++++ 4 files changed, 44 insertions(+), 8 deletions(-) diff --git a/plugins/voyage/lib/validators/brief-validator.mjs b/plugins/voyage/lib/validators/brief-validator.mjs index 293ced9..de22278 100644 --- a/plugins/voyage/lib/validators/brief-validator.mjs +++ b/plugins/voyage/lib/validators/brief-validator.mjs @@ -84,8 +84,12 @@ export function validateBriefContent(text, opts = {}) { } } // Sequencing gate: brief_version ≥ 2.1 requires phase_signals OR phase_signals_partial. - if (typeof fm.brief_version === 'string') { - const vm = fm.brief_version.match(/^(\d+)\.(\d+)$/); + // Coerce to String so the gate fires regardless of whether YAML parsed the value as + // a string ("2.1") or a number (2.1). v5.1.0 shipped with an unquoted-2.1 template + // that silently bypassed this gate — fix locked in by quoting the template AND + // accepting both shapes here as defense-in-depth (v5.1.1, finding 3c834097/df1435a2). + if (typeof fm.brief_version === 'string' || typeof fm.brief_version === 'number') { + const vm = String(fm.brief_version).match(/^(\d+)\.(\d+)$/); if (vm) { const major = Number(vm[1]); const minor = Number(vm[2]); @@ -146,8 +150,8 @@ export function validateBriefContent(text, opts = {}) { } } - if (typeof fm.brief_version === 'string') { - const m = fm.brief_version.match(/^(\d+)\.(\d+)$/); + if (typeof fm.brief_version === 'string' || typeof fm.brief_version === 'number') { + const m = String(fm.brief_version).match(/^(\d+)\.(\d+)$/); if (!m) { warnings.push(issue('BRIEF_VERSION_FORMAT', `brief_version "${fm.brief_version}" not in N.M form`)); } diff --git a/plugins/voyage/templates/trekbrief-template.md b/plugins/voyage/templates/trekbrief-template.md index ff72ac4..e0c2232 100644 --- a/plugins/voyage/templates/trekbrief-template.md +++ b/plugins/voyage/templates/trekbrief-template.md @@ -1,6 +1,6 @@ --- type: trekbrief -brief_version: 2.1 +brief_version: "2.1" created: {YYYY-MM-DD} task: "{one-line task description}" slug: {slug} diff --git a/plugins/voyage/tests/lib/doc-consistency.test.mjs b/plugins/voyage/tests/lib/doc-consistency.test.mjs index 717ee29..b704a52 100644 --- a/plugins/voyage/tests/lib/doc-consistency.test.mjs +++ b/plugins/voyage/tests/lib/doc-consistency.test.mjs @@ -554,10 +554,10 @@ test('operational files no longer reference trekrevise (v5.0.0 removal)', () => // --- v5.1 — phase_signals + brief_version 2.1 --- -test('v5.1 — templates/trekbrief-template.md declares brief_version: 2.1', () => { +test('v5.1 — templates/trekbrief-template.md declares brief_version: "2.1" (quoted)', () => { const t = read('templates/trekbrief-template.md'); - assert.match(t, /^brief_version: 2\.1$/m, - 'trekbrief-template.md must declare brief_version: 2.1 at top of frontmatter'); + assert.match(t, /^brief_version: "2\.1"$/m, + 'trekbrief-template.md must declare brief_version: "2.1" (quoted) — unquoted parses as Number and bypasses sequencing gate'); }); test('v5.1 — templates/trekbrief-template.md contains phase_signals: block', () => { diff --git a/plugins/voyage/tests/validators/brief-validator.test.mjs b/plugins/voyage/tests/validators/brief-validator.test.mjs index a9fd185..69e250f 100644 --- a/plugins/voyage/tests/validators/brief-validator.test.mjs +++ b/plugins/voyage/tests/validators/brief-validator.test.mjs @@ -218,3 +218,35 @@ test('validateBrief — phase_signals with unknown phase rejected', () => { assert.equal(r.valid, false); assert.ok(r.errors.find(e => e.code === 'BRIEF_INVALID_PHASE_SIGNAL_PHASE')); }); + +// --- v5.1.1 regression: YAML-number bypass closed --- +// Findings 3c834097 + df1435a2: v5.1.0 shipped with an unquoted `brief_version: 2.1` +// template. parseScalar coerces unquoted "2.1" to Number 2.1, and the original gate +// guarded `typeof === 'string'`, silently bypassing the sequencing check. v5.1.1 +// coerces via String() so both shapes trigger the gate. + +test('validateBrief — v5.1.1: UNQUOTED brief_version 2.1 without signals triggers gate', () => { + const t = GOOD_BRIEF.replace('brief_version: "2.0"', 'brief_version: 2.1'); + const r = validateBriefContent(t, { strict: true }); + assert.equal(r.valid, false); + assert.ok( + r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS'), + `gate must fire for unquoted brief_version: 2.1 (YAML Number); errors=${JSON.stringify(r.errors)}`, + ); +}); + +test('validateBrief — v5.1.1: QUOTED brief_version "2.1" without signals triggers gate (regression guard)', () => { + const t = GOOD_BRIEF.replace('brief_version: "2.0"', 'brief_version: "2.1"'); + const r = validateBriefContent(t, { strict: true }); + assert.equal(r.valid, false); + assert.ok(r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS')); +}); + +test('validateBrief — v5.1.1: UNQUOTED brief_version 2.1 WITH phase_signals is valid (positive case)', () => { + const t = GOOD_BRIEF + .replace('brief_version: "2.0"', 'brief_version: 2.1') + .replace('source: interview\n', `source: interview\n${SIGNALS_BLOCK}`); + const r = validateBriefContent(t, { strict: true }); + assert.equal(r.valid, true, JSON.stringify(r.errors)); + assert.ok(!r.errors.find(e => e.code === 'BRIEF_V51_MISSING_SIGNALS')); +});