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:
@@ -93,6 +93,20 @@ function isFrontmatterShaped(region: string): boolean {
|
||||
));
|
||||
}
|
||||
|
||||
/**
|
||||
* #3257: a Symbol-keyed channel that carries full-line (column-0 `#`) YAML
|
||||
* comments through a parse → reconstruct round-trip. Comments are otherwise
|
||||
* unrepresentable on the Frontmatter object (Record<string, ...>) and were
|
||||
* silently dropped by reconstructFrontmatter. The Symbol is invisible to
|
||||
* Object.entries / Object.keys / JSON.stringify / for-in, so every existing
|
||||
* reader is unchanged; only reconstructFrontmatter reads it. Leading comments
|
||||
* are attached to the top-level key that follows them; comments after the last
|
||||
* key go to `trailing`. Only set when a comment is actually seen, so comment-less
|
||||
* frontmatter parses byte-identically to before.
|
||||
*/
|
||||
const FULL_LINE_COMMENTS = Symbol('fullLineComments');
|
||||
type FullLineCommentChannel = { leading: Record<string, string[]>; trailing: string[] };
|
||||
|
||||
/**
|
||||
* Parse one already-delimited YAML region into a Frontmatter object.
|
||||
*
|
||||
@@ -104,6 +118,10 @@ function parseYamlRegion(yaml: string): Frontmatter {
|
||||
const frontmatter: Frontmatter = {};
|
||||
const lines = yaml.split(/\r?\n/);
|
||||
|
||||
// #3257: pending column-0 full-line comments, attached to the next top-level key.
|
||||
let pendingComments: string[] = [];
|
||||
let commentChannel: FullLineCommentChannel | undefined;
|
||||
|
||||
// Stack to track nested objects: [{obj, key, indent}]
|
||||
type StackEntry = { obj: Record<string, unknown> | unknown[]; key: string | null; indent: number };
|
||||
const stack: StackEntry[] = [{ obj: frontmatter, key: null, indent: -1 }];
|
||||
@@ -112,6 +130,12 @@ function parseYamlRegion(yaml: string): Frontmatter {
|
||||
// Skip empty lines
|
||||
if (line.trim() === '') continue;
|
||||
|
||||
// #3257: capture column-0 full-line comments; attach them to the next top-level key.
|
||||
if (/^#/.test(line)) {
|
||||
pendingComments.push(line);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Calculate indentation (number of leading spaces)
|
||||
const indentMatch = line.match(/^(\s*)/);
|
||||
const indent = indentMatch ? indentMatch[1].length : 0;
|
||||
@@ -127,6 +151,12 @@ function parseYamlRegion(yaml: string): Frontmatter {
|
||||
const keyMatch = line.match(/^(\s*)([a-zA-Z0-9_-]+):\s*(.*)/);
|
||||
if (keyMatch) {
|
||||
const key = keyMatch[2];
|
||||
// #3257: attach any pending comments to this (top-level) key.
|
||||
if (pendingComments.length) {
|
||||
if (!commentChannel) commentChannel = { leading: {}, trailing: [] };
|
||||
commentChannel.leading[key] = pendingComments;
|
||||
pendingComments = [];
|
||||
}
|
||||
const value = keyMatch[3].trim();
|
||||
|
||||
if (value === '' || value === '[') {
|
||||
@@ -168,6 +198,15 @@ function parseYamlRegion(yaml: string): Frontmatter {
|
||||
}
|
||||
}
|
||||
|
||||
// #3257: trailing comments (after the last key) + attach the channel if any comment was seen.
|
||||
if (pendingComments.length) {
|
||||
if (!commentChannel) commentChannel = { leading: {}, trailing: [] };
|
||||
commentChannel.trailing = pendingComments;
|
||||
}
|
||||
if (commentChannel) {
|
||||
(frontmatter as Record<symbol, unknown>)[FULL_LINE_COMMENTS as unknown as symbol] = commentChannel;
|
||||
}
|
||||
|
||||
return frontmatter;
|
||||
}
|
||||
|
||||
@@ -282,8 +321,14 @@ function scalarNeedsDoubleQuoting(s: string): boolean {
|
||||
|
||||
function reconstructFrontmatter(obj: Frontmatter): string {
|
||||
const lines: string[] = [];
|
||||
// #3257: read the full-line-comment channel (set by parseYamlRegion when comments
|
||||
// were present). Object.entries skips the Symbol key, so the data loop is unchanged.
|
||||
const commentChannel = (obj as Record<symbol, unknown>)[FULL_LINE_COMMENTS as unknown as symbol] as FullLineCommentChannel | undefined;
|
||||
for (const [key, value] of Object.entries(obj)) {
|
||||
if (value === null || value === undefined) continue;
|
||||
// #3257: re-emit this key's leading full-line comments before the key itself.
|
||||
const leading = commentChannel?.leading[key];
|
||||
if (leading) for (const c of leading) lines.push(c);
|
||||
if (Array.isArray(value)) {
|
||||
if (value.length === 0) {
|
||||
lines.push(`${key}: []`);
|
||||
@@ -343,9 +388,34 @@ function reconstructFrontmatter(obj: Frontmatter): string {
|
||||
}
|
||||
}
|
||||
}
|
||||
// #3257: re-emit any trailing full-line comments (those after the last key).
|
||||
if (commentChannel?.trailing?.length) {
|
||||
for (const c of commentChannel.trailing) lines.push(c);
|
||||
}
|
||||
return lines.join('\n');
|
||||
}
|
||||
|
||||
/**
|
||||
* #3257: copy the full-line-comment channel from `source` onto `target`, filtering
|
||||
* `leading` to keys still present in `target` (a deleted key's annotation goes with
|
||||
* it — AC5). No-op when `source` carries no channel. Consumers that rebuild their
|
||||
* target object fresh (syncStateFrontmatter builds derivedFm via buildStateFrontmatter
|
||||
* and copies keys with Object.keys, which skips the Symbol) MUST call this before
|
||||
* reconstructFrontmatter, or the channel parseYamlRegion attached to the extracted
|
||||
* source is lost.
|
||||
*/
|
||||
function propagateCommentChannel(source: Frontmatter, target: Frontmatter): void {
|
||||
const channel = (source as Record<symbol, unknown>)[FULL_LINE_COMMENTS as unknown as symbol] as FullLineCommentChannel | undefined;
|
||||
if (!channel) return;
|
||||
const filtered: FullLineCommentChannel = { leading: {}, trailing: channel.trailing };
|
||||
for (const [key, comments] of Object.entries(channel.leading)) {
|
||||
if (key in target) filtered.leading[key] = comments;
|
||||
}
|
||||
if (filtered.trailing.length || Object.keys(filtered.leading).length) {
|
||||
(target as Record<symbol, unknown>)[FULL_LINE_COMMENTS as unknown as symbol] = filtered;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Slice a frontmatter YAML body into per-top-level-key raw text segments. Each segment
|
||||
* runs from a column-0 `key:` line through the line before the next column-0 key (or the
|
||||
@@ -826,4 +896,5 @@ export = {
|
||||
cmdFrontmatterSet,
|
||||
cmdFrontmatterMerge,
|
||||
cmdFrontmatterValidate,
|
||||
propagateCommentChannel,
|
||||
};
|
||||
|
||||
@@ -27,7 +27,7 @@ const { planningDir, planningPaths } = planningWorkspace;
|
||||
import { realClock } from './clock.cjs';
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import frontmatter = require('./frontmatter.cjs');
|
||||
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter } = frontmatter;
|
||||
const { extractFrontmatter, reconstructFrontmatter, stripFrontmatter, propagateCommentChannel } = frontmatter;
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
import scanPhasePlans = require('./plan-scan.cjs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
@@ -2362,6 +2362,12 @@ function syncStateFrontmatter(content: string, cwd: string | undefined, authorit
|
||||
}
|
||||
}
|
||||
|
||||
// #3257: propagate full-line frontmatter comments from the extracted source onto the
|
||||
// rebuilt derivedFm (buildStateFrontmatter + the Object.keys carry-forward above both
|
||||
// skip the Symbol-keyed channel, so without this the comments would be lost here even
|
||||
// though parseYamlRegion/reconstructFrontmatter preserve them in isolation).
|
||||
propagateCommentChannel(existingFm as unknown as Frontmatter, derivedFm as unknown as Frontmatter);
|
||||
|
||||
const yamlStr = reconstructFrontmatter(derivedFm as unknown as Frontmatter);
|
||||
return `---\n${yamlStr}\n---\n\n${body}`;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user