* feat(#4917): add the PlanningDoc parse -> mutate -> serialize seam Phase 1 of epic #4906, implementing ADR-4910 and its 2026-09-21 amendment. Net-new leaf module; NO call site is migrated, so nothing in the twelve absorbed issues changes behavior yet. src/planning-document.cts composes the seams that already exist rather than reimplementing them: markdown-sectionizer for structure (fences and code spans come from stripFencedCode / scanInlineCodeSpans, never a second scanner), markdown-table for tables, frontmatter for frontmatter, write-set for Result<T>. What is structural rather than conventional: - A field node carries labelSpan, valueSpan and trailingSpan separately, and the only write entry point takes a node id and writes into valueSpan. trailingSpan is readable and has no exported writer, so the #2853/#3584/#4852 rule ("the verb owns the count token ONLY") stops depending on an author remembering a third capture group. - Mutation is node-addressed. A handle is minted by the parser, so a caller cannot name a node the parser did not find. No path strings — a path is a grammar, and a grammar needs a parser. - serialize splices staged spans into the ORIGINAL buffer. A no-edit serialize is byte-identical, which is what eliminates the #4499 defect without a targeted fix, and which also makes an already-escaped table cell impossible to double-escape on a round trip. - A node that fails to parse carries its own error and span; siblings stay readable. - Per the ADR amendment, serialize REFUSES whenever any node carries a parse error, even with zero staged edits, naming the offender. One defect was found while building and fixed in place rather than deferred: PLANNING_ARTIFACTS first derived from isCanonicalPlanningFile unfiltered, so config.json, state.json, milestone.lock and skill-manifest.json were accepted and returned {ok:true, nodes:[]} — "this document records nothing" when the truth was "I have no grammar for this file". That is the exact empty-vs-error confusion this epic exists to remove (#4899, #4900), reproduced inside the seam built to prevent it. The registry now filters to .md while still DERIVING from isCanonicalPlanningFile, because hand-writing a second list is the divergence this epic is about, and parsePlanningDoc refuses a non-markdown kind at the document level per ADR-4910 section 5. New bin/lib module bookkeeping: .gitignore, eslint.config.mjs ignore (ADR-457 — lint the .cts, not the emitted .cjs), docs/INVENTORY.md row, regenerated docs/INVENTORY-MANIFEST.json, and the CONTEXT.md glossary entry. Refs #4906 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * test(#4917): cover the PlanningDoc seam across 28 input classes 30 cases in 14 describe blocks, one per row of the phase test matrix. The two load-bearing tests are the fast-check properties (seed 20260921, numRuns 200): - a single node mutation leaves every byte outside that node's valueSpan identical to the source - serialize with zero staged edits is the identity function Both are DOCUMENT-SHAPED per CONTRIBUTING.md fixture provenance (#2371): the generator assembles arbitrary frontmatter, heading, label and value text with join(), and never calls serialize or any other function from the module under test to build a fixture. Seeding the generator from the module's own writer would make the document shape a constant, and the property could then never explore a document the writer would not itself emit. Verified the byte-range assertion is not vacuous with a control run: it passes against the real writer and FAILS against a simulated #4852 writer (one capture group, replace-to-end-of-line), which visibly drops the trailing annotation. Boundary coverage is zero / one / two staged edits. Negative space carries its own rows — bold emphasis in prose, a field-shaped line inside a fenced block, the same inside an inline code span, and a horizontal rule mid-body all correctly mint no node. Row 24 is the Generative-Fix-Divergence parity assertion: PLANNING_ARTIFACTS must not diverge from isCanonicalPlanningFile. Row 28 covers the non-markdown canonical file found during the build. Assertions are structural throughout — a typed Result / NodeRead / SerializeOutcome shape, or a byte range computed from the node's own Span. Full-string equality appears only where the contract IS byte equality. Refs #4906 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * fix(#4917): apply review findings — refuse unrepresentable values, compose the layers the seam claimed Three review passes ran against this branch: /security-review, an isolated adversarial pass, and a standards+spec pass. Five findings, all fixed here. Every one is the epic's own failure class reproduced inside the seam built to end it, which is the thing worth noticing. 1. setFieldValue accepted a value containing a newline. It survived serialization and reparsed as a REAL sibling field — one write to Plans forged a second Owner into a document that already had one. Content became structure, which defeats ADR-4910 Decision 2's "cannot reach past its own token by construction": the token boundary is a LINE boundary. 2. setFieldValue accepted a value containing the trailing separator and SILENTLY TRUNCATED it. Staged "sneaky - annotation", read back "sneaky", with the remainder reclassified as trailing prose. No error, nothing unreadable, both resulting nodes parsing perfectly. Worse than (1) because it loses the caller's own value rather than adding something visible. Both are fixed by ONE general check, deliberately not a blacklist: setFieldValue rebuilds the candidate line, re-parses it through the same field grammar, and refuses unless the value reads back identical. Blacklisting the separator would close this instance and leave the class open for whatever separator the grammar grows next. The round-trip check is ADR-4910 Decision 4 stated executably. 3. The module reimplemented two layers it claims to compose. Checklist detection hand-rolled a checkbox regex that markdown-sectionizer's iterateBullets already owns. Frontmatter span detection re-derived fence handling because frontmatter.cts's frontmatterRegion was module-private — so ADR-4910 section 1's stated layering was UNREACHABLE as written, and the first implementation routed around it silently instead of surfacing the gap. frontmatterRegion is now exported (additive only; ADR-2143 section 2's extend-never-mutate lock is inherited) and both layers are consumed. 4. The CONTEXT.md glossary entry asserted "the Frontmatter Module supplies frontmatter" while zero frontmatter.cts code was invoked. That was a false claim in the repo's vocabulary of record, written by me, and it is now true rather than edited away. 5. Adopting iterateBullets narrowed GFM coverage: it classifies only dash-prefixed task items as checkboxes, so "* [ ] x" and "+ [x] y" stopped becoming checklist nodes. Widening the sectionizer is forbidden by the inherited lock, so the task-list MARKER is interpreted in this module while bullet STRUCTURE still comes from the sectionizer. The sharpest finding was not a defect. The fast-check generator constrained values to [A-Za-z0-9 .,!?], so it could not emit an em-dash, newline, backtick, pipe or asterisk — precisely where (1) and (2) lived. The property was real, seeded and non-vacuous, and structurally blind to the module's actual bug class. The generator now spans the grammar's own metacharacters, and a new property asserts that every value setFieldValue ACCEPTS round-trips identically. Proven able to fail: against a scratch copy with the guard stripped it fails after 7 cases on newValue "\n". Recorded as a measured boundary, not fixed: a bare CR inside a field line leaves that field unrecognised. Measured — sibling fields still parse, no error node, and serialize stays byte-identical, so the worst case is an unreadable field and never a damaged document. Flagged for Phase 4's empty-vs-error census. Refs #4906 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> * chore(#4917): backfill changeset pr number to 4918 Refs #4906 Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> --------- Co-authored-by: sim <sim@local> Co-authored-by: Claude Opus 5 <noreply@anthropic.com>
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 `.gsd/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 = '../gsd-core/bin/lib/planning-document.cjs';
|
|
const ARTIFACTS_PATH = '../gsd-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);
|
|
});
|
|
});
|