fix(#4010): confine stateReplaceField to same-line whitespace so an empty field's following line survives (#4021)
* fix(#4010): confine stateReplaceField to same-line whitespace so an empty field's following line survives stateReplaceField's bold and plain patterns used `\s*` for the label-to-value gap, which matches the newline after an empty field; `(.*)` then captured the following line and the rebuild discarded it -- silent STATE.md data loss on any `state update` against an empty body field (Status:, Stopped at:, Paused at:), with exit 0 and no warning. Confine the gap to same-line whitespace (`[ \t]*`), mirroring the already-correct read side (stateExtractField, src/state-document.cts:404/:409), and pin the label-value separator to a single space when the label line had none, so an empty field yields `**Status:** value` rather than a glued `**Status:**value`. Non-empty and pipe-table replacements are byte-identical to prior behaviour. ADR-3180 §7.7 makes stateExtractField the same-line-confined owner; this aligns the writer to it. Regression test fails before / passes after and covers bold and plain shapes, LF and CRLF, the non-empty byte-identity guard, and an end-to-end transitionCore characterization at the consumer (ADR-3180 Decision 4(c)). * chore(#4010): add changeset for the stateReplaceField empty-field fix * test(#4010): add boundary and property coverage; scope the changeset's unchanged claim Addresses review on #4021: - Add boundary tests for the shapes the example tests missed: an empty field at end-of-document (no following line, bold + plain), two consecutive empty fields (only the target is filled, the other empty field's line survives), and an empty new value on an empty field (joinFieldReplacement synthesizes no dangling separator and the following line is preserved). - Add a fast-check property over the bold/plain branches and joinFieldReplacement: for any field name, any values (empty fields included), and any new value, replacing one field changes only its own line and never the total line count — the invariant #4010 violated, now guarded directly. - Scope the changeset's "unchanged" claim to ordinary space/tab separators (an exotic vertical-tab/form-feed separator, which no GSD template emits, now normalises to a single space). * test(#4010): pin glued-separator non-empty field, scope joinFieldReplacement JSDoc Round-3 review carried forward a Minor finding: joinFieldReplacement's JSDoc still claimed non-empty replacements are unconditionally "byte-identical to prior behaviour", but a non-empty field written with no label-to-value separator (**Status:**value) gains a single inserted space under the narrowed [ \t]* gap. Round 2 scoped only the changeset prose; the source JSDoc was left making the false unconditional claim. - Scope the JSDoc's byte-identity claim to ordinary space/tab separators and name the no-separator normalization as the one intentional exception. - Add a test pinning the glued-separator case (**Status:**Planning): exactly one space inserted, following line survives, not byte-identical. Emitted .cjs is gitignored (class-1), so no emitted-drift-ack applies. build:lib clean; 74/74 state-document tests pass. Claude-Session: https://claude.ai/code/session_01Mzmut6aeqZ1APfUBAkBZTR --------- Co-authored-by: Tom Boucher <trekkie@nomorestars.com>
This commit is contained in:
@@ -524,17 +524,43 @@ export function stateCurrentPositionSlice(body: string): string | null {
|
||||
return section ? section.body : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Join a matched `**Field:**`/`Field:` label prefix to its new value, inserting a
|
||||
* single space when the prefix does not already end in same-line whitespace.
|
||||
*
|
||||
* On an empty field the same-line `[ \t]*` gap (below) captures nothing, so the
|
||||
* bare `prefix + value` would glue the value to the label (`**Status:**value`);
|
||||
* this inserts the missing separator. A non-empty field whose label-to-value
|
||||
* separator is ordinary space/tab keeps that separator in the prefix, so `[ \t]$`
|
||||
* is true and the output stays byte-identical to prior behaviour. The one
|
||||
* exception is a non-empty field written with NO separator at all (a hand-edited
|
||||
* `**Status:**value`): the narrowed gap captures nothing, `[ \t]$` is false, and a
|
||||
* single space is inserted — an intentional normalization, not byte-identical, and
|
||||
* with no GSD-template trigger. An empty new value inserts no separator, avoiding a
|
||||
* dangling trailing space. See #4010.
|
||||
*/
|
||||
function joinFieldReplacement(prefix: string, newValue: string): string {
|
||||
const value = `${newValue}`;
|
||||
const needsSeparator = value.length > 0 && !/[ \t]$/.test(prefix);
|
||||
return `${prefix}${needsSeparator ? ' ' : ''}${value}`;
|
||||
}
|
||||
|
||||
export function stateReplaceField(content: string, fieldName: string, newValue: string): string | null {
|
||||
const escaped = escapeRegex(fieldName);
|
||||
// Bold inline format: **FieldName:** value
|
||||
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*\\s*)(.*)`, 'i');
|
||||
// The label-to-value gap is same-line whitespace only (`[ \t]*`, mirroring the
|
||||
// read side at stateExtractField). `\s*` here matched `\n`, so on an empty field
|
||||
// `(.*)` captured the following line and the rebuild discarded it — the #4010
|
||||
// data-loss. ADR-3180 §7.7 makes stateExtractField the same-line-confined owner;
|
||||
// this aligns the writer to it.
|
||||
const boldPattern = new RegExp(`(\\*\\*${escaped}:\\*\\*[ \\t]*)(.*)`, 'i');
|
||||
if (boldPattern.test(content)) {
|
||||
return content.replace(boldPattern, (_match, prefix: string) => `${prefix}${newValue}`);
|
||||
return content.replace(boldPattern, (_match, prefix: string) => joinFieldReplacement(prefix, newValue));
|
||||
}
|
||||
// Plain line-start format: FieldName: value
|
||||
const plainPattern = new RegExp(`(^${escaped}:\\s*)(.*)`, 'im');
|
||||
// Plain line-start format: FieldName: value (same same-line confinement as above)
|
||||
const plainPattern = new RegExp(`(^${escaped}:[ \\t]*)(.*)`, 'im');
|
||||
if (plainPattern.test(content)) {
|
||||
return content.replace(plainPattern, (_match, prefix: string) => `${prefix}${newValue}`);
|
||||
return content.replace(plainPattern, (_match, prefix: string) => joinFieldReplacement(prefix, newValue));
|
||||
}
|
||||
// Pipe-table format: | FieldName | value |
|
||||
// Preserve the surrounding pipe/whitespace structure; only swap the value cell.
|
||||
|
||||
Reference in New Issue
Block a user