fix(frontmatter,verify,devcontract): preserve spec line endings - #364
Conversation
All four spec frontmatter writers read through `read_text`, whose universal-newline translation handed them an all-LF copy of a CRLF spec. Writing that back relaid every line ending in the file — LF everywhere on POSIX, CRLF everywhere on Windows — from writes contracted to move a single value. All four now read bytes and decode. The two `frontmatter` writers write bytes as well, and `_replace_value` carries the edited line's own terminator instead of re-emitting a flat `\n`, which would have left a CRLF spec with exactly one bare-LF line. `devcontract`'s write side was already byte-preserving (`_atomic_write_spec`) and `_render_status_line` already carried the `\r` through its `rest` group, so only their reads were wrong. This makes `_edit_frontmatter_block`'s CRLF insert branch reachable for the first time; it is now covered on both `set_frontmatter_field` and `reset_spec_status`. One behavior delta is characterized rather than fixed: `_FRONTMATTER_RE` and `AUTO_RUN_HEADING_RE` are line-oriented on `\r?\n`, so a CR-only spec — which `read_text` used to normalize on the way in — is now a clean no-op through the `devcontract` pair. The `splitlines`-based `set_frontmatter_status` still rewrites that shape; the asymmetry is documented at both writers and pinned in tests. Widening the patterns would change what counts as a line for every reader keyed on them, and no BMAD tool authors CR-only files. `tests/test_frontmatter.py`'s `_as_written` helper is deleted and its ten call sites reverted to bare byte-exact comparisons: it modelled the `write_text` relay so the byte-exact gate could stay green on Windows, and with the relay gone those assertions are the Windows leg's oracle for the write half (POSIX-invisible, since `os.linesep` is `\n` there). Part 1 of #357.
|
Warning Review limit reached
Next review available in: 1 minute Enable usage-based reviews in Billing to review now. Otherwise, wait until the next included review is available. How can I continue?After more reviews become available, a review can be triggered using the To avoid repeated limits, reduce automatic review volume by pausing incremental auto-reviews earlier, using label-based review opt-in, excluding WIP or generated PR titles, or requesting reviews manually when the PR is ready. If your team needs uninterrupted high-volume reviews, an organization admin can enable usage-based reviews. How do review limits work?CodeRabbit enforces per-developer PR review limits for each organization. Most developers receive the normal plan review availability. For paid Pro and Pro+ PR reviews, CodeRabbit uses adaptive limits for sustained high-volume activity. When a developer's recent PR review activity reaches the 95th percentile or higher among CodeRabbit users, additional reviews become available more gradually as earlier reviews age out of the rolling window. Please refer docs for additional details. Review details⚙️ Run configurationConfiguration used: Organization UI Review profile: CHILL Plan: Pro Plus Run ID: 📒 Files selected for processing (7)
Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out. Comment |
The insert branch picked `"\r\n" if block.endswith("\r\n") else "\n"`,
which agrees with the line being followed for CRLF and LF but not for a
bare `\r` — so an insert into a CR-only spec appended the file's only
LF line. Unreachable before the byte-level read normalized nothing; live
now, and it contradicts the same contract the replacement path just
started keeping.
`_last_line_ending` reads the terminator off the block's last line, the
line an inserted key goes directly after, with an `"\n"` fallback for an
empty block or a last line carrying no terminator — neither reachable
through `_split_frontmatter`, but gluing an appended key onto the
previous line is a corruption, not a formatting nit, so it is guarded
rather than reasoned about.
Part 1 of #357.
|
@coderabbitai review |
✅ Action performedReview finished.
|
Greptile SummaryThis PR fixes a line-ending relay defect in four spec frontmatter writers (
Confidence Score: 5/5Safe to merge. The changes are narrowly scoped to the read/write path of four spec writers, and each code path is covered by byte-exact tests that fail on the right regression. The fix is mechanically straightforward (four read_text to read_bytes().decode swaps and two write_text to write_bytes swaps), and the logic in _replace_value and _last_line_ending is correct for every terminator variant (CRLF, LF, bare-CR, no terminator). The one observable behaviour delta — CR-only specs becoming a no-op through the regex-based writers — is explicitly characterised and pinned in tests rather than silently accepted. The test suite is comprehensive: each new test uses dual assertions (byte-exact equality plus a no bare-LF introduced check) that discriminate specific wrong implementations. Files Needing Attention: No files require special attention.
|
| Filename | Overview |
|---|---|
| src/bmad_loop/frontmatter.py | Core fix: read_bytes().decode + write_bytes replaces read_text/write_text; _replace_value now carries each line's own terminator; new _last_line_ending helper ensures inserts cannot introduce a foreign ending. Logic is correct for LF, CRLF, CR, and mixed-ending specs. |
| src/bmad_loop/devcontract.py | reset_spec_status and strip_auto_run_result fixed to read_bytes().decode. _render_status_line was already CRLF-safe (captures \r in rest group); only the read side was wrong. _atomic_write_spec write side was already byte-preserving. |
| src/bmad_loop/verify.py | set_frontmatter_field moved to read_bytes().decode + write_bytes, consistent with the frontmatter.py sibling change. |
| tests/test_frontmatter.py | _spec helper changed to write_bytes; _as_written deleted; new line-endings section adds CRLF, CR-only, and mixed-ending tests with dual assertions. |
| tests/test_devcontract.py | New tests cover reset_spec_status CRLF replace + insert, strip_auto_run_result CRLF preservation, append→strip CRLF round-trip, and the two CR-only characterisation pins. |
| tests/test_resolve.py | New tests cover set_frontmatter_field CRLF replace, CRLF insert, and CR-only insert discriminating the old block.endswith approach from the new _last_line_ending. |
Reviews (1): Last reviewed commit: "fix(frontmatter): carry the block's endi..." | Re-trigger Greptile
Part 1 of #357.
The defect
All four spec frontmatter writers read with
Path.read_text. Universal-newlinehandling means a CRLF spec arrives in memory as LF — in its entirety, not
just the line being edited. Writing that back relays every line ending in the
file, from functions whose contract is "a minimal in-place line replacement so
the spec's formatting, comments, and field order survive — only the value
changes". Two of the four compounded it with
Path.write_text, whosenewline=Nonedefault translates\ntoos.linesep, so on Windows an all-LFspec came back all-CRLF.
frontmatter.set_frontmatter_statusread_text❌write_text❌verify.set_frontmatter_fieldread_text❌write_text❌devcontract.reset_spec_statusread_text❌_atomic_write_spec✅devcontract.strip_auto_run_resultread_text❌_atomic_write_spec✅devcontract's write side was already byte-preserving, and the comment above_atomic_write_spechad already reasoned this out forappend_auto_run_result— which reads bytes. Its two siblings never got the same read.
The fix
read_bytes().decode("utf-8")on all four;write_bytes(....encode("utf-8"))on the two
frontmatterwriters.frontmatter.py's module docstring barsimporting
platform_util(it drags insubprocess), so this cannot borrowatomic_replace— the write stays non-atomic, which is a separate concern froma byte-preserving one and out of scope here.
_replace_valuenow carries the edited line's own terminator rather thanre-emitting a flat
"\n", which would have left a CRLF spec with exactly onebare-LF line — the very line the writer was asked to touch.
splitlines(keepends=True)guarantees at most one terminator per line, so
\r\n,\n, a bare\r, and afinal line with no terminator each round-trip as authored.
devcontract._render_status_lineneeded no change:_FM_STATUS_RE's(?P<rest>.*)$already captures the\rof a raw CRLF line (verified beforetouching it, not assumed).
Side effect: this makes
_edit_frontmatter_block's insert branch reachable forthe first time — before the byte-level read, the block was always LF by the time
it got there. It is now covered on both writers that can insert.
Second commit, flagged for the maintainer as a scope call. Making that branch
live exposed that it hardcoded
"\r\n" if block.endswith("\r\n") else "\n",which agrees with the line it follows for CRLF and LF but not for a bare
\r—so an insert into a CR-only spec appended the file's only LF line. That is the
same hardcoded-
\ndefect as_replace_value's, in the sibling branch of thesame function, and leaving it would have shipped a writer where one branch
carries endings and the other invents them.
_last_line_endingreads theterminator off the block's last line instead. Happy to split this out if you'd
rather part 1 stay strictly to the four read/write swaps.
Behavior delta, pinned rather than fixed
_FRONTMATTER_REandAUTO_RUN_HEADING_REare line-oriented on\r?\n. ACR-only spec used to be normalized by
read_texton the way in, so the editlanded (and the file was rewritten as LF, which was the defect). Reading the
bytes as authored, it finds no frontmatter block / no heading and returns a
silent
False.frontmatter.set_frontmatter_statusissplitlines-based and still rewritesthat same shape, so the two siblings now disagree on CR-only input. That
asymmetry is documented at both writers and pinned by
test_reset_status_no_ops_on_a_cr_only_spec/test_strip_auto_run_result_no_ops_on_a_cr_only_spec,which assert the no-op and that the sibling rewrites the identical bytes.
Widening the patterns to
\rwould change what counts as a line for everyreader keyed on them (and
^inMULTILINEwould not follow anyway) — a largercontract than this fix owns, and no BMAD tool authors CR-only files.
Tests
New coverage, all byte-exact:
tests/test_frontmatter.py— aline endingssection: CRLF whole-file(only the status line changes, plus
"\n" not in text.replace("\r\n", "")),bare-CR round-trip, and a mixed-ending file where the status line is the odd
one out on purpose, so a whole-file
nldetection fails it.tests/test_resolve.py—set_frontmatter_fieldCRLF replace, CRLF insert, anda CR-only insert that pins "the inserted line is never the file's odd one out".
tests/test_devcontract.py—reset_spec_statusCRLF replace + CRLF insert,strip_auto_run_resultCRLF preservation, an append→strip CRLF byte-for-byteround-trip, and the two CR-only characterizations.
_as_written(theos.linesep-modelling helper) is deleted and its ten callsites reverted to bare byte-exact comparisons. It existed to keep the byte-exact
gate green on Windows by describing the relay; with the relay gone, those bare
comparisons are what fails on Windows if the write half regresses.
Ablations
Every gate was reverted individually by a scripted patch → run → restore, with
the restore sha256-verified against the original (no
git checkout <file>).frontmatterread_bytes → read_text_replace_valueper-line ending → bare"\n"_replace_value→ CRLF/LF only (bare CR dropped)_replace_value→ hardcoded"\r\n"verifyread_bytes → read_textdevcontract.reset_spec_statusread revertdevcontract.strip_auto_run_resultread revert_last_line_ending→ the oldblock.endswith("\r\n")branchThe "still passes" rows matter as much as the failures: they show each test
discriminates a specific wrong implementation rather than firing on any change.
One honest limit. Reverting
write_bytes→write_textkeeps the wholesuite green on Linux —
os.linesepis\nthere, so the write half isPOSIX-invisible. The Windows CI leg is its only oracle, via the reverted
byte-exact assertions in the characterization half. That is recorded in a
comment in
tests/test_frontmatter.pyso the next reader does not conclude thewrite change is untested.
Verification
uv run pytest -q -n auto— 3671 passed, 24 skippedtrunk check --no-fix— no issuesuvx pyright@1.1.411— 0 errorsOut of scope, staying in #357: the trailing-inline-comment drop (part 2), moving
set_frontmatter_status's tests out oftests/test_resolve.py, and making thesewrites atomic.