Mechanical rename produced by scripts/msd-rename.cjs: gsd/Gsd/GSD -> msd/Msd/MSD across contents and paths, upstream package/repo coordinates -> @golem15/msd-core and golem15com/msd-core. Deep links into upstream history, sibling upstream packages, the GSD-2 import feature, CHANGELOG.md and .changeset/ are kept as-is. Hand edits on top: MSD block-letter banner and logos, LICENSE copyright line, package/plugin identity, regenerated lockfile, install-tree fixtures, derived registries and benchmark baseline; migration checksum baseline re-locked (MSD keeps its own install state, so no install had applied the old sums); sort-order and regex-escaped expectations in tests adjusted.
782 lines
31 KiB
JavaScript
782 lines
31 KiB
JavaScript
/**
|
|
* PlanningDoc seam — parse -> mutate -> serialize (ADR-4910, epic #4906 Phase 1,
|
|
* #4917). Covers every row of `.msd/phase/feat-4917-planning-document-seam/50-test-matrix.md`.
|
|
*
|
|
* All assertions are structural: either a typed Result/NodeRead/SerializeOutcome
|
|
* shape, or a byte-range comparison computed from the node's OWN `Span` offsets
|
|
* (never a hardcoded literal expectation of rendered prose) — per CONTRIBUTING.md
|
|
* "Prohibited: Raw Text Matching on Test Outputs". Full-string byte-identity
|
|
* comparisons (`assert.strictEqual(result, source)`) are used only where the
|
|
* module's contract IS byte equality: rows 3, 15, 21, 22, 23.
|
|
*/
|
|
|
|
const { describe, test } = require('node:test');
|
|
const assert = require('node:assert/strict');
|
|
const fc = require('fast-check');
|
|
|
|
const MODULE_PATH = '../msd-core/bin/lib/planning-document.cjs';
|
|
const ARTIFACTS_PATH = '../msd-core/bin/lib/artifacts.cjs';
|
|
|
|
let mod;
|
|
try {
|
|
mod = require(MODULE_PATH);
|
|
} catch (err) {
|
|
throw new Error(`Could not require ${MODULE_PATH}. Run "npm run build:lib" first. Underlying: ${err.message}`);
|
|
}
|
|
const { parsePlanningDoc, findField, readNode, setFieldValue, hasUnreadableNodes, serialize, PLANNING_ARTIFACTS } = mod;
|
|
|
|
const artifactsMod = require(ARTIFACTS_PATH);
|
|
const { isCanonicalPlanningFile, CANONICAL_EXACT } = artifactsMod;
|
|
|
|
const ARTIFACT = 'STATE.md';
|
|
const EM_DASH = '—';
|
|
|
|
/** Parse and assert success, returning the PlanningDoc value. */
|
|
function parseOk(source, artifact = ARTIFACT) {
|
|
const result = parsePlanningDoc(source, artifact);
|
|
assert.strictEqual(result.ok, true, `expected parse to succeed: ${JSON.stringify(result)}`);
|
|
return result.value;
|
|
}
|
|
|
|
// ─── Row 1 / 2: happy path — byte-range preservation ───────────────────────────
|
|
|
|
describe('row 1-2: setFieldValue + serialize preserve every byte outside valueSpan', () => {
|
|
test('setFieldValue preserves every byte outside the value span', () => {
|
|
const source = [
|
|
'---',
|
|
'title: Fixture',
|
|
'---',
|
|
'',
|
|
'**Plans:** initial value',
|
|
'**Status:** pending',
|
|
'',
|
|
].join('\n');
|
|
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Plans');
|
|
assert.ok(id);
|
|
const node = doc.nodes.find((n) => n.id === id);
|
|
|
|
const staged = setFieldValue(doc, id, 'updated value');
|
|
assert.strictEqual(staged.ok, true);
|
|
const outcome = serialize(staged.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
|
|
const before = source.slice(0, node.valueSpan.start);
|
|
const after = source.slice(node.valueSpan.end);
|
|
assert.strictEqual(outcome.value.slice(0, node.valueSpan.start), before);
|
|
assert.strictEqual(outcome.value.slice(node.valueSpan.start + 'updated value'.length), after);
|
|
});
|
|
|
|
test('a field write does not touch trailing prose on the same line (#4852/#4862)', () => {
|
|
const source = [
|
|
'---',
|
|
'title: Fixture',
|
|
'---',
|
|
'',
|
|
`**Summary:** short summary ${EM_DASH} hand-written annotation stays`,
|
|
'',
|
|
].join('\n');
|
|
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Summary');
|
|
const node = doc.nodes.find((n) => n.id === id);
|
|
assert.strictEqual(node.kind, 'boldField');
|
|
|
|
// The trailing annotation lives entirely past valueSpan.end.
|
|
const trailingBefore = source.slice(node.valueSpan.end, node.trailingSpan.end);
|
|
|
|
const staged = setFieldValue(doc, id, 'new short summary');
|
|
const outcome = serialize(staged.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
|
|
const trailingAfter = outcome.value.slice(
|
|
node.valueSpan.start + 'new short summary'.length,
|
|
node.valueSpan.start + 'new short summary'.length + trailingBefore.length,
|
|
);
|
|
assert.strictEqual(trailingAfter, trailingBefore);
|
|
});
|
|
});
|
|
|
|
// ─── Row 3/4/5: boundary — limit-1 (0 edits), limit (1 edit), limit+1 (2 edits) ─
|
|
|
|
describe('row 3-5: staged-edit count boundary', () => {
|
|
const source = [
|
|
'---',
|
|
'title: Fixture',
|
|
'---',
|
|
'',
|
|
'**Alpha:** one',
|
|
'**Beta:** two',
|
|
'',
|
|
].join('\n');
|
|
|
|
test('serialize with no edits returns the original bytes (limit-1)', () => {
|
|
const doc = parseOk(source);
|
|
const outcome = serialize(doc);
|
|
assert.strictEqual(outcome.ok, true);
|
|
assert.strictEqual(outcome.value, source);
|
|
});
|
|
|
|
test('a single staged edit splices exactly one span (limit)', () => {
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Alpha');
|
|
const node = doc.nodes.find((n) => n.id === id);
|
|
const staged = setFieldValue(doc, id, 'ONE');
|
|
const outcome = serialize(staged.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
const expectedLength = source.length - (node.valueSpan.end - node.valueSpan.start) + 'ONE'.length;
|
|
assert.strictEqual(outcome.value.length, expectedLength);
|
|
assert.strictEqual(outcome.value.slice(0, node.valueSpan.start), source.slice(0, node.valueSpan.start));
|
|
assert.strictEqual(outcome.value.slice(node.valueSpan.start + 'ONE'.length), source.slice(node.valueSpan.end));
|
|
});
|
|
|
|
test('two edits on different nodes both apply without offset drift (limit+1)', () => {
|
|
const doc = parseOk(source);
|
|
const idA = findField(doc, 'Alpha');
|
|
const idB = findField(doc, 'Beta');
|
|
const nodeA = doc.nodes.find((n) => n.id === idA);
|
|
const nodeB = doc.nodes.find((n) => n.id === idB);
|
|
|
|
const staged1 = setFieldValue(doc, idA, 'ALPHA-NEW');
|
|
const staged2 = setFieldValue(staged1.value, idB, 'BETA-NEW');
|
|
const outcome = serialize(staged2.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
|
|
// Region strictly between the two fields is untouched.
|
|
const between = source.slice(nodeA.valueSpan.end, nodeB.valueSpan.start);
|
|
const resultBetween = outcome.value.slice(
|
|
nodeA.valueSpan.start + 'ALPHA-NEW'.length,
|
|
nodeA.valueSpan.start + 'ALPHA-NEW'.length + between.length,
|
|
);
|
|
assert.strictEqual(resultBetween, between);
|
|
|
|
// Tail after Beta's original span is untouched, offset by both edits' delta.
|
|
const tailBefore = source.slice(nodeB.valueSpan.end);
|
|
const deltaA = 'ALPHA-NEW'.length - (nodeA.valueSpan.end - nodeA.valueSpan.start);
|
|
const deltaB = 'BETA-NEW'.length - (nodeB.valueSpan.end - nodeB.valueSpan.start);
|
|
const tailStart = nodeB.valueSpan.end + deltaA + deltaB;
|
|
assert.strictEqual(outcome.value.slice(tailStart), tailBefore);
|
|
});
|
|
});
|
|
|
|
// ─── Row 6: duplicate/conflicting — same node edited twice ─────────────────────
|
|
|
|
describe('row 6: a second edit to one node replaces the first', () => {
|
|
test('last write wins; span applied once', () => {
|
|
const source = ['---', 'title: Fixture', '---', '', '**Alpha:** one', ''].join('\n');
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Alpha');
|
|
const node = doc.nodes.find((n) => n.id === id);
|
|
|
|
const staged1 = setFieldValue(doc, id, 'first');
|
|
const staged2 = setFieldValue(staged1.value, id, 'second');
|
|
|
|
// Staged map collapses to exactly one entry for this id.
|
|
assert.strictEqual(staged2.value.staged.size, 1);
|
|
assert.strictEqual(staged2.value.staged.get(id), 'second');
|
|
|
|
const outcome = serialize(staged2.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
const expectedLength = source.length - (node.valueSpan.end - node.valueSpan.start) + 'second'.length;
|
|
assert.strictEqual(outcome.value.length, expectedLength);
|
|
assert.strictEqual(readNode(staged2.value, id).value, 'second');
|
|
});
|
|
});
|
|
|
|
// ─── Row 7: negative — id not from this doc ────────────────────────────────────
|
|
|
|
describe('row 7: setFieldValue refuses a node id this document did not mint', () => {
|
|
test('an id minted by a different PlanningDoc is refused', () => {
|
|
const source = ['---', 'title: Fixture', '---', '', '**Alpha:** one', ''].join('\n');
|
|
const docA = parseOk(source);
|
|
const docB = parseOk(source);
|
|
const idFromB = findField(docB, 'Alpha');
|
|
|
|
const result = setFieldValue(docA, idFromB, 'x');
|
|
assert.strictEqual(result.ok, false);
|
|
});
|
|
|
|
test('a wholly unknown id is refused', () => {
|
|
const source = ['---', 'title: Fixture', '---', '', '**Alpha:** one', ''].join('\n');
|
|
const doc = parseOk(source);
|
|
const result = setFieldValue(doc, 'not-a-real-id', 'x');
|
|
assert.strictEqual(result.ok, false);
|
|
});
|
|
});
|
|
|
|
// ─── Row 8/9/10: ragged table — unreadable node + sibling readability ──────────
|
|
|
|
function raggedTableSource() {
|
|
return [
|
|
'---',
|
|
'title: Fixture',
|
|
'---',
|
|
'',
|
|
'**Before:** sibling one',
|
|
'',
|
|
'| A | B |',
|
|
'|---|---|',
|
|
'| onlyone |',
|
|
'',
|
|
'**After:** sibling two',
|
|
'',
|
|
].join('\n');
|
|
}
|
|
|
|
describe('row 8: an unreadable node does not make its siblings unreadable', () => {
|
|
test('the ragged table node carries an error; both bordering fields stay readable', () => {
|
|
const doc = parseOk(raggedTableSource());
|
|
const tableNode = doc.nodes.find((n) => n.kind === 'table');
|
|
assert.ok(tableNode);
|
|
assert.ok(tableNode.error);
|
|
assert.strictEqual(hasUnreadableNodes(doc), true);
|
|
|
|
const beforeId = findField(doc, 'Before');
|
|
const afterId = findField(doc, 'After');
|
|
assert.strictEqual(readNode(doc, beforeId).ok, true);
|
|
assert.strictEqual(readNode(doc, afterId).ok, true);
|
|
|
|
const tableRead = readNode(doc, tableNode.id);
|
|
assert.strictEqual(tableRead.ok, false);
|
|
});
|
|
});
|
|
|
|
describe('row 9-10: serialize refuses on any unreadable node, edit or not', () => {
|
|
test('serialize refuses when any node is unreadable, with a staged edit', () => {
|
|
const doc = parseOk(raggedTableSource());
|
|
const beforeId = findField(doc, 'Before');
|
|
const staged = setFieldValue(doc, beforeId, 'edited');
|
|
assert.strictEqual(staged.ok, true);
|
|
|
|
const outcome = serialize(staged.value);
|
|
assert.strictEqual(outcome.ok, false);
|
|
assert.strictEqual(outcome.reason, 'unreadable-nodes');
|
|
assert.strictEqual(outcome.nodes.length, 1);
|
|
assert.strictEqual(outcome.nodes[0].kind, 'table');
|
|
});
|
|
|
|
test('the refusal is about the document, not the mutation (no staged edit)', () => {
|
|
const doc = parseOk(raggedTableSource());
|
|
assert.strictEqual(doc.staged.size, 0);
|
|
|
|
const outcome = serialize(doc);
|
|
assert.strictEqual(outcome.ok, false);
|
|
assert.strictEqual(outcome.reason, 'unreadable-nodes');
|
|
assert.strictEqual(outcome.nodes.length, 1);
|
|
assert.strictEqual(outcome.nodes[0].kind, 'table');
|
|
});
|
|
});
|
|
|
|
// ─── Row 11/12: empty / whitespace-only input ──────────────────────────────────
|
|
|
|
describe('row 11-12: empty and whitespace-only documents', () => {
|
|
test('an empty document parses to no nodes, not to could-not-parse', () => {
|
|
const doc = parseOk('');
|
|
assert.strictEqual(doc.nodes.length, 0);
|
|
});
|
|
|
|
test('a whitespace-only document is empty, not unparseable', () => {
|
|
const doc = parseOk(' \n\t\n \n');
|
|
assert.strictEqual(doc.nodes.length, 0);
|
|
});
|
|
});
|
|
|
|
// ─── Row 13/14: document-level malformed input ─────────────────────────────────
|
|
|
|
describe('row 13-14: document-level Result failure', () => {
|
|
test('a document with no frontmatter terminator fails at the document level', () => {
|
|
const source = ['---', 'title: Fixture', 'this fence is never closed', ''].join('\n');
|
|
const result = parsePlanningDoc(source, ARTIFACT);
|
|
assert.strictEqual(result.ok, false);
|
|
});
|
|
|
|
test('parsing a non-planning document fails at the document level', () => {
|
|
const source = ['---', 'title: Fixture', '---', '', '**Alpha:** one', ''].join('\n');
|
|
// The identical source parses OK under a canonical artifact name...
|
|
const good = parsePlanningDoc(source, ARTIFACT);
|
|
assert.strictEqual(good.ok, true);
|
|
// ...and fails purely because of the artifact kind under a bogus one.
|
|
const bad = parsePlanningDoc(source, 'NOT-A-PLANNING-FILE.md');
|
|
assert.strictEqual(bad.ok, false);
|
|
});
|
|
|
|
test('a non-markdown canonical planning file is refused at the document level', () => {
|
|
// config.json/state.json/milestone.lock are canonical per artifacts.cjs but
|
|
// carry no markdown grammar — this must be a document-level refusal, never
|
|
// a successful empty document (row 28 / design.md row 16).
|
|
assert.ok(isCanonicalPlanningFile('config.json'));
|
|
const result = parsePlanningDoc('{}', 'config.json');
|
|
assert.strictEqual(result.ok, false);
|
|
});
|
|
});
|
|
|
|
// ─── Row 15: CRLF round-trip ────────────────────────────────────────────────────
|
|
|
|
describe('row 15: CRLF documents round-trip byte-identically', () => {
|
|
const source = ['---', 'title: Fixture', '---', '', '**Alpha:** one', '**Beta:** two', ''].join('\r\n');
|
|
|
|
test('no-op serialize on a CRLF document is byte-identical', () => {
|
|
const doc = parseOk(source);
|
|
const outcome = serialize(doc);
|
|
assert.strictEqual(outcome.ok, true);
|
|
assert.strictEqual(outcome.value, source);
|
|
});
|
|
|
|
test('a single edit on a CRLF document preserves every byte outside valueSpan', () => {
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Alpha');
|
|
const node = doc.nodes.find((n) => n.id === id);
|
|
const staged = setFieldValue(doc, id, 'ALPHA');
|
|
const outcome = serialize(staged.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
assert.strictEqual(outcome.value.slice(0, node.valueSpan.start), source.slice(0, node.valueSpan.start));
|
|
assert.strictEqual(outcome.value.slice(node.valueSpan.start + 'ALPHA'.length), source.slice(node.valueSpan.end));
|
|
});
|
|
});
|
|
|
|
// ─── Row 16: hostile field value — markdown metacharacters ─────────────────────
|
|
|
|
describe('row 16: a field value containing markdown metacharacters round-trips', () => {
|
|
test('pipes, backticks, and bold markers read back verbatim', () => {
|
|
const rawValue = 'a | b `code` **bold** end';
|
|
const source = ['---', 'title: Fixture', '---', '', `**Data:** ${rawValue}`, ''].join('\n');
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Data');
|
|
assert.strictEqual(readNode(doc, id).value, rawValue);
|
|
|
|
const node = doc.nodes.find((n) => n.id === id);
|
|
const staged = setFieldValue(doc, id, rawValue);
|
|
const outcome = serialize(staged.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
assert.strictEqual(outcome.value.slice(0, node.valueSpan.start), source.slice(0, node.valueSpan.start));
|
|
assert.strictEqual(outcome.value.slice(node.valueSpan.start + rawValue.length), source.slice(node.valueSpan.end));
|
|
});
|
|
});
|
|
|
|
// ─── Row 17-20: negative space ──────────────────────────────────────────────────
|
|
|
|
describe('row 17-20: negative space (looks like the target, is not)', () => {
|
|
test('bold emphasis in prose is not a field label', () => {
|
|
const source = [
|
|
'---',
|
|
'title: Fixture',
|
|
'---',
|
|
'',
|
|
'This is **emphasis** in prose, not a field.',
|
|
'**Bold statement** without a colon.',
|
|
'',
|
|
].join('\n');
|
|
const doc = parseOk(source);
|
|
assert.strictEqual(doc.nodes.some((n) => n.kind === 'boldField'), false);
|
|
});
|
|
|
|
test('a field-shaped line inside a fenced block is not a node', () => {
|
|
const source = ['---', 'title: Fixture', '---', '', '```', '**Label:** value', '```', ''].join('\n');
|
|
const doc = parseOk(source);
|
|
assert.strictEqual(findField(doc, 'Label'), null);
|
|
assert.strictEqual(doc.nodes.some((n) => n.kind === 'boldField'), false);
|
|
});
|
|
|
|
test('a field-shaped line inside an inline code span is not a node', () => {
|
|
const source = ['---', 'title: Fixture', '---', '', '`**Label:** value`', ''].join('\n');
|
|
const doc = parseOk(source);
|
|
assert.strictEqual(findField(doc, 'Label'), null);
|
|
});
|
|
|
|
test('a horizontal rule is not a frontmatter terminator', () => {
|
|
const source = [
|
|
'---',
|
|
'title: Fixture',
|
|
'---',
|
|
'',
|
|
'## Section',
|
|
'',
|
|
'Some text.',
|
|
'',
|
|
'---',
|
|
'',
|
|
'More text after the rule.',
|
|
].join('\n');
|
|
const doc = parseOk(source);
|
|
const frontmatterNodes = doc.nodes.filter((n) => n.kind === 'frontmatter');
|
|
assert.strictEqual(frontmatterNodes.length, 1);
|
|
// The frontmatter span ends at the FIRST closing fence, well before the
|
|
// horizontal rule further down the document.
|
|
const secondRuleOffset = source.lastIndexOf('\n---\n');
|
|
assert.ok(frontmatterNodes[0].span.end < secondRuleOffset);
|
|
});
|
|
});
|
|
|
|
// ─── Row 21: hostile round-trip — pre-escaped table cell ───────────────────────
|
|
|
|
describe('row 21: an untouched escaped cell is not re-escaped', () => {
|
|
test('a pre-escaped pipe in a table cell is byte-identical with zero edits', () => {
|
|
const source = [
|
|
'---',
|
|
'title: Fixture',
|
|
'---',
|
|
'',
|
|
'| Col A | Col B |',
|
|
'|---|---|',
|
|
'| has \\| pipe | plain |',
|
|
'',
|
|
].join('\n');
|
|
const doc = parseOk(source);
|
|
assert.strictEqual(hasUnreadableNodes(doc), false);
|
|
const outcome = serialize(doc);
|
|
assert.strictEqual(outcome.ok, true);
|
|
assert.strictEqual(outcome.value, source);
|
|
});
|
|
});
|
|
|
|
// ─── Row 22/23: document-shaped fast-check properties ──────────────────────────
|
|
//
|
|
// Per CONTRIBUTING.md fixture-provenance (#2371): the generator below builds
|
|
// documents directly from arbitrary frontmatter/heading/label/value TEXT
|
|
// pieces assembled with `.join('\n')` — it never calls `serialize` (or any
|
|
// other function from the module under test) to produce its fixtures. Seeding
|
|
// from the module's own writer would make the document shape a constant and
|
|
// the property could never explore a shape the writer wouldn't itself emit.
|
|
|
|
const safeLabelArb = fc
|
|
.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,12}$/)
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0);
|
|
|
|
// Values are deliberately widened to include the grammar's own metacharacters
|
|
// (em-dash, hyphen, `*_|[]#:`, bare \n/\r, non-ASCII letters) — this is the
|
|
// exact input space where the setFieldValue representability defects lived
|
|
// (forged sibling via \n; silent truncation on the trailing " — " separator).
|
|
// Labels stay narrow: they are a different, narrower grammar.
|
|
//
|
|
// safeValueArb feeds an INITIAL field's own literal text on ONE physical
|
|
// line of the generated document (`**label:** value`) — it excludes bare
|
|
// \n/\r because embedding a line break there would corrupt the generator's
|
|
// own one-line-per-field assumption (the field would not parse as a
|
|
// boldField at all, which is a generator bug, not a module defect). newValue
|
|
// is only ever passed AS AN ARGUMENT to setFieldValue, never spliced
|
|
// directly into document text, so it carries the full alphabet including
|
|
// \n/\r — exactly what setFieldValue must correctly refuse.
|
|
const HOSTILE_LINE_SAFE_CHARS = 'A-Za-z0-9 .,!?—\\-*_`|\\[\\]#:\\u00C0-\\u024F\\u3040-\\u30FF\\u4E00-\\u9FFF';
|
|
const HOSTILE_VALUE_CHARS = `${HOSTILE_LINE_SAFE_CHARS}\\n\\r`;
|
|
const hostileLineSafeArb = (max) => fc.stringMatching(new RegExp(`^[${HOSTILE_LINE_SAFE_CHARS}]{0,${max}}$`));
|
|
const hostileValueArb = (max) => fc.stringMatching(new RegExp(`^[${HOSTILE_VALUE_CHARS}]{0,${max}}$`));
|
|
|
|
const safeValueArb = hostileLineSafeArb(20);
|
|
|
|
const fieldArb = fc.record({ label: safeLabelArb, value: safeValueArb });
|
|
|
|
const documentPiecesArb = fc.record({
|
|
heading: fc
|
|
.stringMatching(/^[A-Za-z][A-Za-z0-9 ]{0,15}$/)
|
|
.map((s) => s.trim())
|
|
.filter((s) => s.length > 0),
|
|
fields: fc.uniqueArray(fieldArb, { minLength: 1, maxLength: 5, selector: (r) => r.label.toLowerCase() }),
|
|
mutateIndex: fc.nat(),
|
|
newValue: hostileValueArb(25),
|
|
});
|
|
|
|
/** Assemble a document TEXT from arbitrary document-shaped pieces (never via
|
|
* the module's own serializer — see the #2371 note above). */
|
|
function buildDocumentText({ heading, fields }) {
|
|
return [
|
|
'---',
|
|
'title: generated fixture',
|
|
'---',
|
|
'',
|
|
`## ${heading}`,
|
|
'',
|
|
...fields.map((f) => `**${f.label}:** ${f.value}`),
|
|
'',
|
|
'Trailing prose line unrelated to any field.',
|
|
].join('\n');
|
|
}
|
|
|
|
describe('row 22-23: document-shaped fast-check properties', () => {
|
|
test('property: a single node mutation leaves every other byte identical', () => {
|
|
fc.assert(
|
|
fc.property(documentPiecesArb, (pieces) => {
|
|
const source = buildDocumentText(pieces);
|
|
const parsed = parsePlanningDoc(source, ARTIFACT);
|
|
assert.strictEqual(parsed.ok, true);
|
|
const doc = parsed.value;
|
|
|
|
const idx = pieces.mutateIndex % pieces.fields.length;
|
|
const label = pieces.fields[idx].label;
|
|
const id = findField(doc, label);
|
|
assert.ok(id, `expected to find field ${JSON.stringify(label)}`);
|
|
|
|
const node = doc.nodes.find((n) => n.id === id);
|
|
const staged = setFieldValue(doc, id, pieces.newValue);
|
|
if (!staged.ok) return; // a representability refusal is a valid outcome, not a failure
|
|
const outcome = serialize(staged.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
|
|
const before = source.slice(0, node.valueSpan.start);
|
|
const after = source.slice(node.valueSpan.end);
|
|
assert.strictEqual(outcome.value.slice(0, node.valueSpan.start), before);
|
|
assert.strictEqual(outcome.value.slice(node.valueSpan.start + pieces.newValue.length), after);
|
|
}),
|
|
{ seed: 20260921, numRuns: 200 },
|
|
);
|
|
});
|
|
|
|
test('property: serialize with no edits is the identity function', () => {
|
|
fc.assert(
|
|
fc.property(documentPiecesArb, (pieces) => {
|
|
const source = buildDocumentText(pieces);
|
|
const parsed = parsePlanningDoc(source, ARTIFACT);
|
|
assert.strictEqual(parsed.ok, true);
|
|
const outcome = serialize(parsed.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
assert.strictEqual(outcome.value, source);
|
|
}),
|
|
{ seed: 20260921, numRuns: 200 },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Row 31: representability — every ACCEPTED value round-trips identically ───
|
|
|
|
describe('row 31: every value setFieldValue accepts round-trips identically', () => {
|
|
test('property: every value setFieldValue ACCEPTS round-trips identically', () => {
|
|
fc.assert(
|
|
fc.property(documentPiecesArb, (pieces) => {
|
|
const source = buildDocumentText(pieces);
|
|
const parsed = parsePlanningDoc(source, ARTIFACT);
|
|
assert.strictEqual(parsed.ok, true);
|
|
const doc = parsed.value;
|
|
|
|
const idx = pieces.mutateIndex % pieces.fields.length;
|
|
const label = pieces.fields[idx].label;
|
|
const id = findField(doc, label);
|
|
assert.ok(id, `expected to find field ${JSON.stringify(label)}`);
|
|
|
|
const staged = setFieldValue(doc, id, pieces.newValue);
|
|
if (!staged.ok) return; // refusing is a PASS — the whole point of the representability check
|
|
|
|
const outcome = serialize(staged.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
|
|
const reparsed = parsePlanningDoc(outcome.value, ARTIFACT);
|
|
assert.strictEqual(reparsed.ok, true);
|
|
const reId = findField(reparsed.value, label);
|
|
assert.ok(reId, `expected to re-find field ${JSON.stringify(label)} after round-trip`);
|
|
const read = readNode(reparsed.value, reId);
|
|
assert.strictEqual(read.ok, true);
|
|
assert.strictEqual(read.value, pieces.newValue);
|
|
}),
|
|
{ seed: 20260921, numRuns: 300 },
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Row 24: registry parity — Generative-Fix-Divergence guard ─────────────────
|
|
|
|
describe('row 24: the artifact registry does not diverge from isCanonicalPlanningFile', () => {
|
|
test('every PLANNING_ARTIFACTS entry is a real canonical planning file', () => {
|
|
assert.ok(Array.isArray(PLANNING_ARTIFACTS));
|
|
assert.ok(PLANNING_ARTIFACTS.length > 0);
|
|
for (const name of PLANNING_ARTIFACTS) {
|
|
assert.strictEqual(isCanonicalPlanningFile(name), true, `${name} should be canonical`);
|
|
}
|
|
});
|
|
|
|
test('PLANNING_ARTIFACTS is exactly the markdown subset of CANONICAL_EXACT', () => {
|
|
const expected = Array.from(CANONICAL_EXACT).filter((name) => name.endsWith('.md'));
|
|
assert.deepStrictEqual(new Set(PLANNING_ARTIFACTS), new Set(expected));
|
|
});
|
|
|
|
test('a non-markdown canonical entry is excluded from PLANNING_ARTIFACTS', () => {
|
|
assert.ok(CANONICAL_EXACT.has('config.json'));
|
|
assert.strictEqual(PLANNING_ARTIFACTS.includes('config.json'), false);
|
|
});
|
|
});
|
|
|
|
// ─── Row 25: one positive control per declared grammar ─────────────────────────
|
|
|
|
describe('row 25: every declared grammar has a positive control', () => {
|
|
test('frontmatter, section, boldField, table, and checklist each parse to a node of that kind', () => {
|
|
const source = [
|
|
'---',
|
|
'title: Fixture',
|
|
'---',
|
|
'',
|
|
'## A Section',
|
|
'',
|
|
'**Field:** a value',
|
|
'',
|
|
'| Col A | Col B |',
|
|
'|---|---|',
|
|
'| x | y |',
|
|
'',
|
|
'- [ ] todo one',
|
|
'- [x] todo two',
|
|
'',
|
|
].join('\n');
|
|
const doc = parseOk(source);
|
|
const kinds = new Set(doc.nodes.map((n) => n.kind));
|
|
for (const expectedKind of ['frontmatter', 'section', 'boldField', 'table', 'checklist']) {
|
|
assert.ok(kinds.has(expectedKind), `expected a ${expectedKind} node; got kinds: ${[...kinds].join(', ')}`);
|
|
}
|
|
});
|
|
});
|
|
|
|
// ─── Row 26: unicode heading / label — byte-honest offsets ─────────────────────
|
|
|
|
describe('row 26: unicode labels and headings keep byte-honest offsets', () => {
|
|
test('a unicode field label and value parse and round-trip correctly', () => {
|
|
const source = ['---', 'title: Fixture', '---', '', '## Resume 日本語', '', '**日本語:** 値', ''].join(
|
|
'\n',
|
|
);
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, '日本語');
|
|
assert.ok(id);
|
|
assert.strictEqual(readNode(doc, id).value, '値');
|
|
|
|
const node = doc.nodes.find((n) => n.id === id);
|
|
const staged = setFieldValue(doc, id, '新しい値');
|
|
const outcome = serialize(staged.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
assert.strictEqual(outcome.value.slice(0, node.valueSpan.start), source.slice(0, node.valueSpan.start));
|
|
assert.strictEqual(
|
|
outcome.value.slice(node.valueSpan.start + '新しい値'.length),
|
|
source.slice(node.valueSpan.end),
|
|
);
|
|
});
|
|
});
|
|
|
|
// ─── Row 27: bounded-size — large document splices without offset corruption ───
|
|
|
|
describe('row 27: a large document splices without offset corruption', () => {
|
|
test('a document with thousands of lines still splices exactly one span', () => {
|
|
const lineCount = 4000;
|
|
const lines = ['---', 'title: Large Fixture', '---', ''];
|
|
for (let i = 0; i < lineCount; i++) {
|
|
lines.push(`Prose line number ${i} filling out the document body.`);
|
|
}
|
|
lines.push('**Target:** the value to mutate');
|
|
for (let i = 0; i < lineCount; i++) {
|
|
lines.push(`More prose line number ${i} after the target field.`);
|
|
}
|
|
const source = lines.join('\n');
|
|
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Target');
|
|
assert.ok(id);
|
|
const node = doc.nodes.find((n) => n.id === id);
|
|
|
|
const staged = setFieldValue(doc, id, 'MUTATED');
|
|
const outcome = serialize(staged.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
assert.strictEqual(outcome.value.slice(0, node.valueSpan.start), source.slice(0, node.valueSpan.start));
|
|
assert.strictEqual(outcome.value.slice(node.valueSpan.start + 'MUTATED'.length), source.slice(node.valueSpan.end));
|
|
assert.strictEqual(outcome.value.length, source.length - (node.valueSpan.end - node.valueSpan.start) + 'MUTATED'.length);
|
|
});
|
|
});
|
|
|
|
// ─── Row 29: negative — a line-break value must not become a forged sibling ───
|
|
|
|
describe('row 29: setFieldValue refuses a value carrying a line break', () => {
|
|
const source = [
|
|
'---',
|
|
'title: Fixture',
|
|
'---',
|
|
'',
|
|
'**Plans:** initial value',
|
|
'**Owner:** alice',
|
|
'',
|
|
].join('\n');
|
|
|
|
test('a value containing \\n is refused', () => {
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Plans');
|
|
const result = setFieldValue(doc, id, '1/1\n**Owner:** mallory');
|
|
assert.strictEqual(result.ok, false);
|
|
});
|
|
|
|
test('a value containing a bare \\r is refused', () => {
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Plans');
|
|
const result = setFieldValue(doc, id, '1/1\r**Owner:** mallory');
|
|
assert.strictEqual(result.ok, false);
|
|
});
|
|
|
|
test('a value containing \\r\\n is refused', () => {
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Plans');
|
|
const result = setFieldValue(doc, id, '1/1\r\n**Owner:** mallory');
|
|
assert.strictEqual(result.ok, false);
|
|
});
|
|
|
|
test('the negative proof: a refused write leaves the original document untouched', () => {
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Plans');
|
|
const result = setFieldValue(doc, id, '1/1\n**Owner:** mallory');
|
|
assert.strictEqual(result.ok, false);
|
|
|
|
const outcome = serialize(doc);
|
|
assert.strictEqual(outcome.ok, true);
|
|
assert.strictEqual(outcome.value, source);
|
|
assert.strictEqual(outcome.value.includes('mallory'), false);
|
|
assert.strictEqual(readNode(doc, findField(doc, 'Owner')).value, 'alice');
|
|
});
|
|
|
|
test('legitimate values still stage successfully: plain, empty, **, and |', () => {
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Plans');
|
|
|
|
for (const value of ['plain value', '', '**bold marker**', 'a | b']) {
|
|
const result = setFieldValue(doc, id, value);
|
|
assert.strictEqual(result.ok, true);
|
|
}
|
|
});
|
|
|
|
test('a full round trip with a legitimate value leaves the sibling field intact exactly once', () => {
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Plans');
|
|
const staged = setFieldValue(doc, id, '2/2');
|
|
assert.strictEqual(staged.ok, true);
|
|
const outcome = serialize(staged.value);
|
|
assert.strictEqual(outcome.ok, true);
|
|
|
|
const ownerMatches = outcome.value.match(/\*\*Owner:\*\*/g);
|
|
assert.strictEqual(ownerMatches.length, 1);
|
|
const ownerId = findField(staged.value, 'Owner');
|
|
assert.strictEqual(readNode(staged.value, ownerId).value, 'alice');
|
|
});
|
|
});
|
|
|
|
// ─── Row 30: negative — a value carrying the trailing separator ───────────────
|
|
|
|
describe('row 30: setFieldValue refuses a value containing the trailing separator', () => {
|
|
const source = [
|
|
'---',
|
|
'title: Fixture',
|
|
'---',
|
|
'',
|
|
'**Plans:** initial value',
|
|
'**Owner:** alice',
|
|
'',
|
|
].join('\n');
|
|
|
|
test('a value containing the trailing " — " separator is refused', () => {
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Plans');
|
|
const result = setFieldValue(doc, id, `sneaky ${EM_DASH} annotation`);
|
|
assert.strictEqual(result.ok, false);
|
|
});
|
|
|
|
test('after refusal, the document serializes byte-identically to the source', () => {
|
|
const doc = parseOk(source);
|
|
const id = findField(doc, 'Plans');
|
|
const result = setFieldValue(doc, id, `sneaky ${EM_DASH} annotation`);
|
|
assert.strictEqual(result.ok, false);
|
|
|
|
const outcome = serialize(doc);
|
|
assert.strictEqual(outcome.ok, true);
|
|
assert.strictEqual(outcome.value, source);
|
|
});
|
|
});
|