fix(#3257): preserve full-line frontmatter comments through the parse→reconstruct pair + syncStateFrontmatter (#3387)

* test(#3257: full-line frontmatter comments survive the parse→reconstruct pair AND a mutating state verb

parseYamlRegion dropped column-0 # comments and reconstructFrontmatter rebuilt
from Object.entries alone, so full-line comments were silently destroyed on
every mutating STATE verb. Add failing-first regressions: 3 unit tests for the
public pair (comment between keys, leading+trailing, consecutive) and an e2e
test running a state verb (state update) on a commented STATE.md — the e2e
exercises syncStateFrontmatter's fresh-derivedFm rebuild path, which is the
actual loss site the issue is filed against.

RED — fails on next; fix follows.

* fix(#3257: preserve full-line frontmatter comments through parse→reconstruct AND syncStateFrontmatter

Carry column-0 # comments through the frontmatter pair via a Symbol-keyed
channel (FULL_LINE_COMMENTS): parseYamlRegion captures ^# lines and attaches
them to the next top-level key (leading) or a trailing slot; reconstructFrontmatter
re-emits them in place. The Symbol is invisible to Object.entries/keys/JSON, so
every existing reader is unchanged; the channel is created only when a comment
is seen, so comment-less frontmatter is byte-identical.

CRITICAL (isolated review): syncStateFrontmatter rebuilds its target via
buildStateFrontmatter (fresh object) + an Object.keys carry-forward, both of
which skip the Symbol — so the pair-preserving channel was lost on the very
STATE verbs the issue names. Export propagateCommentChannel(source, target)
from frontmatter.cts and call it in syncStateFrontmatter before reconstruct,
copying the channel onto derivedFm (leading filtered to keys still present so a
deleted key's annotation drops with it, trailing preserved). Decision A.

* chore(#3257: add changeset fragment

* chore(#3257: backfill changeset PR number (#3387)

---------

Co-authored-by: sim <sim@local>
This commit is contained in:
Tom Boucher
2026-08-12 09:14:54 -04:00
committed by GitHub
parent 4a0b5e26b4
commit 2b20b7e2cd
5 changed files with 157 additions and 1 deletions

View File

@@ -295,6 +295,47 @@ describe('reconstructFrontmatter', () => {
const extracted2 = extractFrontmatter(roundTrip);
assert.deepStrictEqual(extracted2, extracted1, 'round-trip should preserve multiple data types');
});
test('#3257: full-line comments survive an extract→reconstruct round-trip', () => {
const original = '---\ngsd_state_version: 1.0\n# NOTE: current_phase is hand-maintained here\ncurrent_phase: 3\nstatus: executing\n---';
const extracted = extractFrontmatter(original);
assert.strictEqual(extracted['gsd_state_version'], '1.0');
assert.strictEqual(extracted['current_phase'], '3');
const reconstructed = reconstructFrontmatter(extracted);
// #3257: the comment must survive in place (between gsd_state_version and current_phase).
assert.ok(
reconstructed.includes('# NOTE: current_phase is hand-maintained here'),
`comment should survive reconstruct; got:\n${reconstructed}`,
);
// data identity preserved alongside the comment.
assert.ok(reconstructed.includes('gsd_state_version: 1.0'));
assert.ok(reconstructed.includes('current_phase: 3'));
assert.ok(reconstructed.includes('status: executing'));
// the reconstructed output re-parses to the same data (idempotent round-trip).
const reextracted = extractFrontmatter(`---\n${reconstructed}\n---`);
assert.strictEqual(reextracted['current_phase'], '3');
});
test('#3257: leading (before first key) and trailing (after last key) comments survive', () => {
const original = '---\n# top comment\na: 1\nb: 2\n# trailing comment\n---';
const extracted = extractFrontmatter(original);
const reconstructed = reconstructFrontmatter(extracted);
assert.ok(reconstructed.includes('# top comment'), `leading comment lost:\n${reconstructed}`);
assert.ok(reconstructed.includes('# trailing comment'), `trailing comment lost:\n${reconstructed}`);
});
test('#3257: multiple consecutive comments survive in order', () => {
const original = '---\na: 1\n# first note\n# second note\nb: 2\n---';
const extracted = extractFrontmatter(original);
const reconstructed = reconstructFrontmatter(extracted);
assert.ok(reconstructed.includes('# first note') && reconstructed.includes('# second note'),
`consecutive comments lost:\n${reconstructed}`);
const aIdx = reconstructed.indexOf('a: 1');
const firstIdx = reconstructed.indexOf('# first note');
const secondIdx = reconstructed.indexOf('# second note');
assert.ok(aIdx < firstIdx && firstIdx < secondIdx, `order wrong (a:${aIdx} first:${firstIdx} second:${secondIdx})`);
});
});
// ─── spliceFrontmatter ──────────────────────────────────────────────────────

View File

@@ -915,6 +915,39 @@ team: platform
assert.ok(content.includes('status: executing'), 'schema-owned status still preserved');
});
test('#3257: full-line frontmatter comments survive a mutating state verb', () => {
// syncStateFrontmatter rebuilds frontmatter via buildStateFrontmatter (fresh object)
// + an Object.keys carry-forward. Without propagating the comment channel, the
// comment is lost HERE even though the parse→reconstruct pair preserves it in
// isolation. This is the e2e path the issue is filed against.
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),
`---
status: executing
milestone: v1.0
# NOTE: current_phase is hand-maintained here while the roadmap is in flux
current_phase: 3
---
# Project State
**Current Phase:** 03
**Current Plan:** 03-02
`
);
// Any writeStateMd triggers syncStateFrontmatter.
runGsdTools('state update "Current Plan" "03-03"', tmpDir);
const content = fs.readFileSync(path.join(tmpDir, '.planning', 'STATE.md'), 'utf-8');
assert.ok(
content.includes('# NOTE: current_phase is hand-maintained here while the roadmap is in flux'),
`full-line frontmatter comment must survive a mutating verb; got:\n${content}`,
);
// The mutation itself still applied.
assert.ok(content.includes('03-03'), 'the state update still took effect');
});
test('round-trip: write then read via state json', () => {
fs.writeFileSync(
path.join(tmpDir, '.planning', 'STATE.md'),