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:
Rezolv
2026-09-03 17:14:12 -04:00
committed by GitHub
parent 02c6955162
commit a788afb120
3 changed files with 185 additions and 5 deletions

View File

@@ -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.