Files
msd-core/tests/frontmatter.property.test.cjs
Behruz Nassre Esfahani d96c7ef865 fix(#1779): emit valid YAML for unsafe scalars in reconstructFrontmatter (#1807)
* fix(#1779): emit valid YAML for unsafe scalars in reconstructFrontmatter

reconstructFrontmatter wraps a scalar or block-array item in double quotes
when it contains a YAML indicator (`:`/`#`, plus `[`/`{` for top-level
scalars), but interpolated the raw value with no escaping. A value carrying
both an indicator and a literal `"` (or `\`) serialized to invalid YAML, e.g.
`upstream: "https://x (Tom; "Git. Ship. Done")"`, which fails under any strict
parser (js-yaml, PyYAML) and corrupts the whole block on the next
syncStateFrontmatter whole-block regen.

- escapeDoubleQuoted() escapes `\`, `"`, and control chars (newline/tab/CR/C0
  controls + DEL → YAML `\n`/`\t`/`\r`/`\xHH`) so any wrapped value is valid.
- scalarNeedsDoubleQuoting() routes values that mis-parse or round-trip lossily
  when bare through that escaped form: the empty string (bare `k:` reloads as
  null), an embedded `"`/`\` or control char, a leading YAML indicator
  (quote / `&`*`!` anchor-alias-tag / `|`>` block scalar / flow `[]{},` / `#` /
  reserved `%`@`backtick / `-`?`:` before a space), or leading/trailing space.
  Applied at all four wrap sites.

Scope stays on serialization correctness; it does NOT broaden the lossy
object-list handling deferred to #1572/#1660. Known limitation: lone UTF-16
surrogates are still lossy through UTF-8 encoding (out of scope, extremely
unlikely in frontmatter values).

Regression test asserts strict js-yaml round-trip across all four wrap sites
plus backslash, control-char (incl. NUL), empty-string, leading-indicator, and
leading/trailing-whitespace classes; test-the-test confirms each fails on the
unescaped output. Frontmatter suite 549/549 green, golden-install-parity 16/16
unchanged (no serialization churn).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

* chore(#1779): add changeset for frontmatter quote-escaping fix

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-02 11:43:47 -04:00

296 lines
12 KiB
JavaScript

'use strict';
/**
* Property-based tests for frontmatter.cjs
*
* Module: gsd-core/bin/lib/frontmatter.cjs
* Exported (pure): extractFrontmatter, reconstructFrontmatter, spliceFrontmatter
*
* Properties tested:
* (a) extractFrontmatter never throws on ANY string input (including binary/unicode)
* (b) extractFrontmatter always returns a plain object (not null, not array)
* (c) round-trip: reconstructFrontmatter(extractFrontmatter(spliceFrontmatter(content, obj)))
* preserves key-value pairs for simple flat string values
* (d) spliceFrontmatter never throws on any string/object combination
* (e) extractFrontmatter returns {} for content without a leading ---...--- block
* (f) prohibitions bijection (#644): over a generated must_haves.prohibitions block,
* parseMustHavesBlock(spliceFrontmatter(doc, parseFrontmatter(doc)), 'prohibitions')
* deepEquals the original parse — the new parse ↔ splice path is identity-preserving.
*/
const { describe, test } = require('node:test');
const assert = require('node:assert/strict');
const fc = require('./helpers/fast-check-setup.cjs');
const yaml = require('js-yaml');
const {
extractFrontmatter,
reconstructFrontmatter,
spliceFrontmatter,
parseFrontmatter,
parseMustHavesBlock,
} = require('../gsd-core/bin/lib/frontmatter.cjs');
// ─── Arbitraries ─────────────────────────────────────────────────────────────
// Simple YAML key: alphanumeric + underscore, at least 1 char
const yamlKey = fc.stringMatching(/^[a-z][a-z0-9_]{0,19}$/);
// Simple YAML scalar value: printable ASCII without : ' " # newlines
const yamlScalarValue = fc.stringMatching(/^[a-zA-Z0-9 ._/-]{1,40}$/);
// ─── Tests ────────────────────────────────────────────────────────────────────
describe('frontmatter: extractFrontmatter properties', () => {
// (a) Never throws on any string input
test('property: extractFrontmatter never throws on arbitrary binary/unicode input', () => {
fc.assert(
fc.property(
fc.oneof(
fc.string({ unit: 'binary', maxLength: 300 }),
fc.string({ unit: 'grapheme-composite', maxLength: 300 }),
fc.constant(''),
fc.constant('---\n---'),
fc.constant('---\nkey: value\n---\n# body'),
fc.string({ maxLength: 300 })
),
(input) => {
assert.doesNotThrow(
() => extractFrontmatter(input),
`extractFrontmatter threw on input: ${JSON.stringify(input.slice(0, 50))}`
);
}
)
);
});
// (b) Always returns a plain object
test('property: extractFrontmatter always returns a plain object', () => {
fc.assert(
fc.property(
fc.oneof(
fc.string({ unit: 'binary', maxLength: 200 }),
fc.string({ unit: 'grapheme-composite', maxLength: 200 }),
fc.string({ maxLength: 200 })
),
(input) => {
const result = extractFrontmatter(input);
assert.ok(
typeof result === 'object' && result !== null && !Array.isArray(result),
`extractFrontmatter must return plain object, got ${JSON.stringify(result)}`
);
}
)
);
});
// (e) Returns {} for content without leading --- block
test('property: content without leading --- block returns empty object', () => {
fc.assert(
fc.property(
fc.oneof(
fc.string({ minLength: 0, maxLength: 200 }).filter((s) => !s.startsWith('---')),
fc.constant('# Just a heading'),
fc.constant('plain text content'),
fc.constant('')
),
(input) => {
const result = extractFrontmatter(input);
assert.deepEqual(
result,
{},
`Expected {} for non-frontmatter input, got ${JSON.stringify(result)}`
);
}
)
);
});
});
describe('frontmatter: reconstructFrontmatter properties', () => {
test('property: reconstructFrontmatter never throws on plain objects with string values', () => {
fc.assert(
fc.property(
fc.dictionary(yamlKey, yamlScalarValue, { maxKeys: 10 }),
(obj) => {
assert.doesNotThrow(
() => reconstructFrontmatter(obj),
`reconstructFrontmatter threw on ${JSON.stringify(obj)}`
);
}
)
);
});
test('property: reconstructFrontmatter output is a string', () => {
fc.assert(
fc.property(
fc.dictionary(yamlKey, yamlScalarValue, { maxKeys: 8 }),
(obj) => {
const result = reconstructFrontmatter(obj);
assert.ok(typeof result === 'string', `Expected string got ${typeof result}`);
}
)
);
});
test('property: reconstructFrontmatter on {} returns empty string', () => {
assert.equal(reconstructFrontmatter({}), '');
});
});
describe('frontmatter: spliceFrontmatter properties', () => {
// (d) Never throws on any combination
test('property: spliceFrontmatter never throws on arbitrary content + object', () => {
fc.assert(
fc.property(
fc.string({ maxLength: 300 }),
fc.dictionary(yamlKey, yamlScalarValue, { maxKeys: 8 }),
(content, obj) => {
assert.doesNotThrow(
() => spliceFrontmatter(content, obj),
`spliceFrontmatter threw on content=${JSON.stringify(content.slice(0, 30))}`
);
}
)
);
});
test('property: spliceFrontmatter always returns a string', () => {
fc.assert(
fc.property(
fc.string({ maxLength: 200 }),
fc.dictionary(yamlKey, yamlScalarValue, { maxKeys: 5 }),
(content, obj) => {
const result = spliceFrontmatter(content, obj);
assert.ok(typeof result === 'string', `Expected string got ${typeof result}`);
}
)
);
});
// (c) Round-trip: splice then extract preserves flat string keys
test('property: splice then extract round-trip preserves flat string values', () => {
fc.assert(
fc.property(
fc.string({ maxLength: 100 }), // existing document body
// Only keys + simple values without colons/hashes that would confuse the minimal parser
fc.dictionary(
fc.stringMatching(/^[a-z][a-z0-9]{0,14}$/),
fc.stringMatching(/^[a-zA-Z0-9]{1,30}$/),
{ minKeys: 1, maxKeys: 5 }
),
(body, obj) => {
const spliced = spliceFrontmatter(body, obj);
const extracted = extractFrontmatter(spliced);
for (const [key, value] of Object.entries(obj)) {
if (typeof value === 'string' && value.length > 0) {
assert.equal(
extracted[key],
value,
`Round-trip failed for key=${key}: expected ${value} got ${extracted[key]}`
);
}
}
}
)
);
});
});
// ─── (f) prohibitions bijection (#644) ────────────────────────────────────────
// Locks the new parseMustHavesBlock(…, 'prohibitions') ↔ spliceFrontmatter path that
// the prohibition probe adds. The example-based version lives in
// tests/prohibition-probe.schema.test.cjs; this generalizes it over generated blocks.
// YAML-safe scalar: starts with a letter, no colon/quote/hash/newline (so it parses as a
// plain string and is never coerced to a number by the parser's /^\d+$/ check).
const safeScalar = fc.stringMatching(/^[A-Za-z][A-Za-z0-9 ._-]{0,50}$/);
// One prohibition item with structurally realistic key shape per ADR-550 D7a:
// resolved → carries a verification tier (test|judgment)
// dismissed → carries a non-empty reason (+ a tier)
// unresolved→ neither
const prohibitionItem = fc.oneof(
fc.record({ statement: safeScalar, status: fc.constant('resolved'),
verification: fc.constantFrom('test', 'judgment') }),
fc.record({ statement: safeScalar, status: fc.constant('dismissed'),
verification: fc.constantFrom('test', 'judgment'), reason: safeScalar }),
fc.record({ statement: safeScalar, status: fc.constant('unresolved') })
);
// Emit a frontmatter doc with a must_haves.prohibitions sibling block (keys in a fixed
// order: statement, status, verification?, reason?). Quoted strings carry the values.
function buildDoc(items) {
const lines = ['---', 'phase: 01-x', 'plan: 01', 'must_haves:',
' truths:', ' - "User sees a daily reminder"', ' prohibitions:'];
for (const it of items) {
lines.push(` - statement: "${it.statement}"`);
lines.push(` status: ${it.status}`);
if (it.verification !== undefined) lines.push(` verification: ${it.verification}`);
if (it.reason !== undefined) lines.push(` reason: "${it.reason}"`);
}
lines.push('---', '', 'Body text unchanged.', '');
return lines.join('\n');
}
describe('frontmatter: prohibitions parse ↔ splice bijection (#644)', () => {
test('property: generated prohibitions parse back with their statement and status', () => {
fc.assert(
fc.property(fc.array(prohibitionItem, { minLength: 1, maxLength: 5 }), (items) => {
const doc = buildDoc(items);
const parsed = parseMustHavesBlock(doc, 'prohibitions');
assert.equal(parsed.length, items.length, 'every prohibition item must parse out');
for (let i = 0; i < items.length; i++) {
assert.equal(parsed[i].statement, items[i].statement, `statement[${i}] mismatch`);
assert.equal(parsed[i].status, items[i].status, `status[${i}] mismatch`);
}
})
);
});
test('property: parse -> splice -> re-parse is identity-preserving for prohibitions', () => {
fc.assert(
fc.property(fc.array(prohibitionItem, { minLength: 1, maxLength: 5 }), (items) => {
const doc = buildDoc(items);
const before = parseMustHavesBlock(doc, 'prohibitions');
const parsed = parseFrontmatter(doc);
const spliced = spliceFrontmatter(doc, parsed.frontmatter ?? parsed);
const after = parseMustHavesBlock(spliced, 'prohibitions');
assert.deepEqual(after, before,
'prohibitions must survive a splice/re-parse round-trip unchanged');
})
);
});
});
// #1779 — reconstructFrontmatter must emit YAML that a STRICT parser accepts and
// that preserves string values. The bijective contract is
// ∀ s: yaml.load(reconstructFrontmatter({ k: s })).k === s
// over the documented safe-input subset. Two classes are out of scope and
// excluded here, not silently passed:
// - lone UTF-16 surrogates (lossy through UTF-8 encoding) — filtered via
// fc.pre(s.isWellFormed());
// - numeric/boolean/null-looking BARE strings (e.g. "42", "true", "-5") that a
// YAML loader resolves to a non-string type — a separate pre-existing bug
// class (valid YAML, wrong type), so we assert equality only when the value
// loads back AS a string. An escaping defect (invalid YAML) still fails
// loudly because yaml.load() throws.
describe('frontmatter: reconstructFrontmatter strict-YAML property (#1779)', () => {
test('property: every string value serializes to valid YAML and string-round-trips', () => {
fc.assert(
fc.property(fc.string({ maxLength: 200 }), (s) => {
fc.pre(s.isWellFormed());
// Throws → reconstructFrontmatter emitted invalid YAML → property fails
// (fast-check shrinks + prints the replay seed automatically).
const loaded = yaml.load(reconstructFrontmatter({ k: s }));
if (typeof loaded.k === 'string') {
assert.equal(loaded.k, s,
`value did not round-trip through strict YAML: ${JSON.stringify(s)}`);
}
})
);
});
});