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.
This commit is contained in:
Kjell Tore Guttormsen 2026-05-14 21:36:10 +02:00
commit 4c85a2c22b
4 changed files with 44 additions and 8 deletions

View file

@ -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`));
}

View file

@ -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}

View file

@ -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', () => {

View file

@ -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'));
});