Skip to content

fix(frontmatter): read a YAML-null status as blank, not "none" - #368

Merged
pbean merged 1 commit into
mainfrom
fix/358-yaml-null-status
Jul 29, 2026
Merged

fix(frontmatter): read a YAML-null status as blank, not "none"#368
pbean merged 1 commit into
mainfrom
fix/358-yaml-null-status

Conversation

@pbean

@pbean pbean commented Jul 29, 2026

Copy link
Copy Markdown
Collaborator

Closes #358.

devcontract.RECONCILABLE_FROM's comment claims "" covers "a blank or missing frontmatter status:". A missing one did. A blank one did not: YAML parses a bare status: as null, status_of did str(fm.get("status", "")), and str(None) is "none" — so the blank-status shape landed in the "unknown custom token" arm and was left untouched, while devcontract._FM_STATUS_RE's own comment says the writer side deliberately fills exactly that shape ("a bmad-dev-auto template can leave it blank").

The fix

Guard None before str() in frontmatter.status_of, the single point all spec-frontmatter status gates read through. A YAML-null status now reads "", the same as a missing key.

The other candidate from the issue — adding "none" to RECONCILABLE_FROM — is not taken. It would be dead code (the sole consumer already hand-normalized None first), it leaves a stringified None as a load-bearing token, and it breaks the byte-exact set pin in tests/test_devcontract.py.

The deliberate-token semantic is preserved exactly. PyYAML resolves only ~ / null / Null / NULL / empty as null, so a literal status: none is the string "none" and still reads back as "none". The guard only touches values that were never written as a token at all.

Blast radius

status_of is deliberately wide by design, so I measured it. Of its 12 call sites, zero flip a branch on "none""" — no gate, allowlist, or comparison changes verdict. Only message text moves, in two places, both handled here:

  • operatoractions.committed_drift rendered f"its spec now says status: {self.spec_status}", which would now trail off after status: on a blank spec. It renders (blank). (Not !r — six assertions across test_operatoractions.py and test_cli.py pin the exact string its spec now says status: done.)
  • stories.state_label labels a bare-status story present — its documented KIND_PRESENT fallback — instead of none, which was never a documented value; the fallback was being dodged because "none" is truthy. Text status board and dry-run only; --json carries no story labels. Classification is wedged either way, so the engine's behavior is unchanged.

engine._reconcile_generic_terminal_status already hand-normalized None"" locally right before its RECONCILABLE_FROM check, with a comment naming this exact defect. That workaround is retired — it now reads through status_of like everything else.

Tests

  • test_status_of_normalizes had a misleading (None, "") row: the parametrize ternary turned it into {}, so it characterized the missing key and {"status": None} was never covered. Restructured to pass dicts directly, with rows for {}, {"status": None} (the fix), and {"status": "none"} (the deliberate token).
  • New test_generic_reconcile_leaves_unknown_custom_status: a spec at status: needs-triage with a prose Status: done is left byte-identical and journals no reconcile.
  • Docstring corrected on test_generic_reconcile_advances_bare_null_frontmatter_status — it described the retired engine-local workaround. The test stays green.
  • A committed_drift row pinning (blank).

Ablations (per AGENTS.md — a negative assertion passes for every reason a value could be absent):

  • Deleted the fm_status not in devcontract.RECONCILABLE_FROM guard → the new unknown-token test FAILS, needs-triage rewritten to done. reset_spec_status's line regex matches needs-triage happily, so the allowlist is the only thing standing between a deliberate token and the repair write.
  • Reverted the status_of guard (keeping the engine workaround removed) → the {"status": None} row, test_generic_reconcile_advances_bare_null_frontmatter_status, and the new (blank) drift test all FAIL.

Both restored by re-editing, never git checkout <file>.

Full suite green (3679 passed, 24 skipped), trunk check --no-fix clean, pyright 1.1.411 clean.

Summary by CodeRabbit

  • Bug Fixes
    • Blank YAML status: values are now treated as blank rather than "none".
    • Reconciliation correctly handles missing and blank statuses while preserving unknown custom statuses.
    • Drift messages now clearly display blank statuses as status: (blank).
    • Stories-mode board labels report bare-status stories as present instead of none.
  • Documentation
    • Updated the changelog with the revised blank-status behavior.

A bare `status:` line parses as null and status_of stringified it, so every
status gate saw the token "none" — which devcontract.RECONCILABLE_FROM then
treated as a deliberate custom status and refused to reconcile, contradicting
its own comment and _FM_STATUS_RE's writer side. Normalize None to "" at
status_of (a literal `status: none` still reads "none") and retire the
engine-local workaround at the reconcile.

Closes #358
@coderabbitai

coderabbitai Bot commented Jul 29, 2026

Copy link
Copy Markdown

Review Change Stack

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Organization UI

Review profile: CHILL

Plan: Pro Plus

Run ID: 34b5eb58-0000-42e0-bded-7ae0e093be19

📥 Commits

Reviewing files that changed from the base of the PR and between ca5acbb and 9a55185.

📒 Files selected for processing (8)
  • CHANGELOG.md
  • src/bmad_loop/devcontract.py
  • src/bmad_loop/engine.py
  • src/bmad_loop/frontmatter.py
  • src/bmad_loop/operatoractions.py
  • tests/test_engine.py
  • tests/test_operatoractions.py
  • tests/test_verify.py

Walkthrough

YAML-null or bare status: frontmatter now normalizes to "". Generic reconciliation uses this shared behavior, unknown custom statuses remain unchanged, and parked-story drift messages display blank statuses as (blank). Tests and the changelog document the updated behavior.

Changes

Blank status behavior

Layer / File(s) Summary
Status normalization contract
src/bmad_loop/frontmatter.py, tests/test_verify.py
status_of treats YAML-null as blank while retaining trimming, case normalization, and string conversion for other values.
Generic status reconciliation
src/bmad_loop/engine.py, src/bmad_loop/devcontract.py, tests/test_engine.py
Generic reconciliation uses the shared status normalization, and unknown custom statuses remain untouched.
Blank status drift reporting
src/bmad_loop/operatoractions.py, tests/test_operatoractions.py, CHANGELOG.md
Drift output renders blank specification statuses as (blank), with corresponding test and changelog updates.

Estimated code review effort: 2 (Simple) | ~10 minutes

Possibly related PRs

Suggested reviewers: dracic

Poem

A rabbit found a blank status line,
And made its meaning clear and fine.
No more “none” where silence stays,
Drift now speaks in readable ways.
Custom words remain their own—
Hop, hop, stable status stone!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 62.50% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed Concise and specific; it accurately summarizes the main YAML-null status handling fix.
Linked Issues check ✅ Passed Implements the requested YAML-null normalization in status_of and updates reconciliation, messaging, and tests accordingly.
Out of Scope Changes check ✅ Passed All code and test changes support the blank-status fix; no unrelated functionality appears introduced.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/358-yaml-null-status

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.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@greptile-apps

greptile-apps Bot commented Jul 29, 2026

Copy link
Copy Markdown

Greptile Summary

This PR fixes a bug where a bare status: YAML frontmatter line (which PyYAML parses as null) was being stringified to "none" by status_of, causing it to be treated as a deliberate custom token and blocking automatic spec reconciliation. The fix is a one-liner guard in frontmatter.status_of — the single choke point all status gates read through — that maps None to "", matching the behavior for a missing key.

  • Core fix (frontmatter.py): raw = fm.get(\"status\", \"\") + (\"\" if raw is None else str(raw)).strip().lower() — YAML-null now reads \"\" while a literal status: none string is preserved.
  • Engine cleanup (engine.py): The local None-normalization workaround in _reconcile_generic_terminal_status is retired; the function now delegates to verify.status_of like every other call site.
  • Rendering fix (operatoractions.py): Drift message now renders status: (blank) instead of status: with an empty tail when the spec has a blank status.
  • Test coverage (test_verify.py, test_engine.py, test_operatoractions.py): Tests are restructured to explicitly cover {\"status\": None} as a distinct case from {}; new tests cover unknown custom token preservation and blank-status drift rendering.

Confidence Score: 5/5

Safe to merge — the fix is a minimal one-liner at the single normalization choke point, backed by targeted new tests that explicitly cover the previously untested YAML-null case and preserve the deliberate-custom-token boundary.

The change is narrow: one expression in status_of, a local workaround removal in the engine, a display fallback in operatoractions, and a comment update. Every changed branch is exercised by new or restructured tests, the old parametrize ternary that silently collapsed the YAML-null case to a missing-key test is corrected, and the ablation section of the PR description confirms both the allowlist guard and the status_of guard are individually load-bearing. No call site semantics change for any status other than YAML-null.

Files Needing Attention: No files require special attention.

Important Files Changed

Filename Overview
src/bmad_loop/frontmatter.py Core fix: adds a None guard before str() in status_of so YAML-null reads "" instead of "none"; minimal, correct, and well-documented.
src/bmad_loop/engine.py Retires local None-normalization workaround in _reconcile_generic_terminal_status; now delegates to verify.status_of, which is the correct single-point fix.
src/bmad_loop/devcontract.py Comment-only update to RECONCILABLE_FROM documentation reflecting that status_of now handles YAML-null normalization.
src/bmad_loop/operatoractions.py Drift message now renders "(blank)" instead of an empty tail when spec_status is ""; correctly uses the truthiness of "" to trigger the fallback.
tests/test_verify.py Test restructured to pass dicts directly, explicitly adding {"status": None} as a distinct row from {} (missing key) and {"status": "none"} (literal token).
tests/test_engine.py Adds test_generic_reconcile_leaves_unknown_custom_status asserting byte-identical spec survival for a deliberate custom token; docstring fix on the null-status test.
tests/test_operatoractions.py New test verifies that a blank YAML-null status renders the drift message as "its spec now says status: (blank)" and marks the story as non-confirmable.
CHANGELOG.md Accurate changelog entry describing the bug, root cause, fix, and rendering side-effects.

Reviews (1): Last reviewed commit: "fix(frontmatter): read a YAML-null statu..." | Re-trigger Greptile

@pbean
pbean merged commit bd4827f into main Jul 29, 2026
11 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

devcontract: a blank status: reads as "none", so it is not in RECONCILABLE_FROM

1 participant