* fix(#1572): preserve must_haves object-lists across frontmatter set/merge spliceFrontmatter round-tripped the WHOLE frontmatter through extractFrontmatter (a scalar-only parser) then reconstructFrontmatter (a lossy serializer), so any must_haves object-list — artifacts {path, provides}, prohibitions {statement, status} — was flattened to scalar strings and re-emitted as a malformed inline array whenever an UNRELATED field changed, silently dropping every provides:/ status: value. The write now preserves the original raw text for any top-level key whose value is structurally unchanged between the original parse and the new object (generalizing the existing whole-document no-op guard to per-key fidelity), and regenerates only the key that actually changed. The key set is still defined by newObj (the cmdSet/cmdMerge flow always passes the full merged object). spliceFrontmatter's only callers are cmdFrontmatterSet/Merge — the STATE.md read-modify-write family calls reconstructFrontmatter directly and is unaffected. Regression cases folded into tests/frontmatter-cli.test.cjs: artifacts/prohibitions object-lists survive set and merge; idempotent on repeat sets. Asserted via parseMustHavesBlock (the structure-preserving parser). * chore(#1572): backfill changeset pr ref to 1656 * fix(#1572): fail-closed when set/merge would emit [object Object] (codex review) Adversarial review (codex, gpt-5.5/high) flagged that directly setting a must_haves object-list (a CHANGED key) still routed through the lossy reconstructFrontmatter, emitting literal "[object Object]" and destroying the data. The reported case (mutating an UNRELATED field) was already fixed by per-key raw-text preservation, but the changed-object-list path was still silently lossy. Add fail-closed: when a regenerated key's text contains the "[object Object]" sentinel, spliceFrontmatter throws — cmdFrontmatterSet/Merge error out WITHOUT writing, directing the user to edit the file directly. The no-frontmatter (generate-from-scratch) path is guarded the same way. Adds a test that a refused set leaves the file unchanged and the original object-list intact. Codex finding #2 (a contrived flattened-projection no-op) is a deeper limitation noted in the PR — non-destructive, and the fail-closed message already directs users to edit object-list blocks directly. * test(#1572): add spliceFrontmatter per-key preservation + fail-closed unit coverage Stryker mutates gsd-core/bin/lib/frontmatter.cjs against tests/frontmatter.{property,unit}.test.cjs (MinScore 62). The #1572 regression cases live in frontmatter-cli.test.cjs, which is NOT in Stryker's test set, so the new functions (sliceTopLevelFrontmatterSegments, the per-key preserve/regenerate/drop/append loop, regenerateFrontmatterKey's [object Object] fail-closed) had surviving mutants that dropped the module below threshold. Add unit-level coverage in frontmatter.unit.test.cjs exercising every new branch directly via spliceFrontmatter: unchanged object-list preserved (provides survives) when a scalar sibling changes; changed scalar regenerates only that key; orphan keys dropped; new keys appended; indented nested block stays attached to its parent key; whole-document no-op returns input verbatim; both fail-closed paths (changed object-list + no-frontmatter) throw.
This commit is contained in:
@@ -274,3 +274,131 @@ body`;
|
||||
assert.ok(parsed.error, 'Should have error field');
|
||||
});
|
||||
});
|
||||
|
||||
// ─── frontmatter set/merge: must_haves object-list preservation (#1572) ──────
|
||||
// `frontmatter set`/`merge` round-tripped the WHOLE frontmatter through the lossy
|
||||
// extractFrontmatter → reconstructFrontmatter pair, which flattens must_haves
|
||||
// object-list items ({path, provides} maps) to scalar strings and re-emits them as a
|
||||
// malformed inline array — destroying `provides:` whenever an UNRELATED field changed.
|
||||
// The fix preserves the original raw text for any structurally-unchanged top-level key.
|
||||
const { parseMustHavesBlock } = require('../gsd-core/bin/lib/frontmatter.cjs');
|
||||
|
||||
describe('frontmatter set/merge preserves must_haves object-lists (#1572)', () => {
|
||||
const ARTIFACTS_PLAN = [
|
||||
'---',
|
||||
'phase: 1',
|
||||
'wave: 1',
|
||||
'plan: 01-01',
|
||||
'type: implementation',
|
||||
'depends_on: []',
|
||||
'files_modified: []',
|
||||
'autonomous: true',
|
||||
'must_haves:',
|
||||
' artifacts:',
|
||||
' - path: src/foo.ts',
|
||||
' provides: the foo',
|
||||
' - path: src/bar.ts',
|
||||
' provides: the bar',
|
||||
'---',
|
||||
'# body',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
const PROHIBITIONS_PLAN = [
|
||||
'---',
|
||||
'phase: 1',
|
||||
'wave: 1',
|
||||
'must_haves:',
|
||||
' prohibitions:',
|
||||
' - statement: no direct DB calls',
|
||||
' status: enforced',
|
||||
' - statement: no print statements',
|
||||
' status: pending',
|
||||
'---',
|
||||
'# body',
|
||||
'',
|
||||
].join('\n');
|
||||
|
||||
function runAndParse(plan, cmdArgsForFile) {
|
||||
const file = writeTempFile(plan);
|
||||
runGsdTools(cmdArgsForFile(file));
|
||||
const after = fs.readFileSync(file, 'utf-8');
|
||||
return after;
|
||||
}
|
||||
|
||||
test('set on an unrelated scalar preserves every must_haves.artifacts entry (path + provides)', () => {
|
||||
const after = runAndParse(ARTIFACTS_PLAN, f => ['frontmatter', 'set', f, '--field', 'wave', '--value', '2']);
|
||||
assert.deepEqual(
|
||||
parseMustHavesBlock(after, 'artifacts'),
|
||||
[
|
||||
{ path: 'src/foo.ts', provides: 'the foo' },
|
||||
{ path: 'src/bar.ts', provides: 'the bar' },
|
||||
],
|
||||
'must_haves.artifacts object-list must survive a set on an unrelated field (#1572)',
|
||||
);
|
||||
});
|
||||
|
||||
test('merge of an unrelated field preserves every must_haves.artifacts entry', () => {
|
||||
const after = runAndParse(ARTIFACTS_PLAN, f => ['frontmatter', 'merge', f, '--data', JSON.stringify({ wave: 2 })]);
|
||||
assert.deepEqual(
|
||||
parseMustHavesBlock(after, 'artifacts'),
|
||||
[
|
||||
{ path: 'src/foo.ts', provides: 'the foo' },
|
||||
{ path: 'src/bar.ts', provides: 'the bar' },
|
||||
],
|
||||
'must_haves.artifacts object-list must survive a merge of an unrelated field (#1572)',
|
||||
);
|
||||
});
|
||||
|
||||
test('must_haves.prohibitions object-list is preserved on an unrelated set (same code path)', () => {
|
||||
const after = runAndParse(PROHIBITIONS_PLAN, f => ['frontmatter', 'set', f, '--field', 'wave', '--value', '2']);
|
||||
assert.deepEqual(
|
||||
parseMustHavesBlock(after, 'prohibitions'),
|
||||
[
|
||||
{ statement: 'no direct DB calls', status: 'enforced' },
|
||||
{ statement: 'no print statements', status: 'pending' },
|
||||
],
|
||||
'must_haves.prohibitions object-list must survive a set on an unrelated field (#1572)',
|
||||
);
|
||||
});
|
||||
|
||||
test('round-trip is stable: setting wave twice still preserves artifacts (per-key preservation is idempotent)', () => {
|
||||
const file = writeTempFile(ARTIFACTS_PLAN);
|
||||
runGsdTools(['frontmatter', 'set', file, '--field', 'wave', '--value', '2']);
|
||||
runGsdTools(['frontmatter', 'set', file, '--field', 'wave', '--value', '3']);
|
||||
const after = fs.readFileSync(file, 'utf-8');
|
||||
assert.deepEqual(
|
||||
parseMustHavesBlock(after, 'artifacts'),
|
||||
[
|
||||
{ path: 'src/foo.ts', provides: 'the foo' },
|
||||
{ path: 'src/bar.ts', provides: 'the bar' },
|
||||
],
|
||||
'must_haves.artifacts must survive repeated sets on an unrelated field',
|
||||
);
|
||||
});
|
||||
|
||||
test('directly setting must_haves to a new object-list fails closed instead of emitting [object Object] (#1572 codex review)', () => {
|
||||
// A CHANGED key whose value is an object-list cannot be faithfully serialized by the
|
||||
// lossy writer (it would emit "[object Object]"). Rather than silently destroy the
|
||||
// data, spliceFrontmatter throws — the command fails and the file is left unchanged.
|
||||
const file = writeTempFile(ARTIFACTS_PLAN);
|
||||
const result = runGsdTools([
|
||||
'frontmatter', 'set', file, '--field', 'must_haves',
|
||||
'--value', JSON.stringify({ artifacts: [{ path: 'src/new.ts', provides: 'new thing' }] }),
|
||||
]);
|
||||
assert.ok(
|
||||
!result.success,
|
||||
'frontmatter set of a must_haves object-list must fail closed (refuse to emit "[object Object]")',
|
||||
);
|
||||
const after = fs.readFileSync(file, 'utf-8');
|
||||
assert.ok(!/\[object Object\]/.test(after), 'the file must not contain "[object Object]" after a refused set');
|
||||
assert.deepEqual(
|
||||
parseMustHavesBlock(after, 'artifacts'),
|
||||
[
|
||||
{ path: 'src/foo.ts', provides: 'the foo' },
|
||||
{ path: 'src/bar.ts', provides: 'the bar' },
|
||||
],
|
||||
'the original must_haves.artifacts must be intact after the refused set',
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
Reference in New Issue
Block a user