Skip to content

deferredwork: ANY_HEADING_RE misses indented, tab-separated and empty ATX headings, so a later gate: absorbs into the preceding entry #516

Description

@pbean

ANY_HEADING_RE (src/bmad_loop/deferredwork.py:26) bounds a canonical entry:

ANY_HEADING_RE = re.compile(r"^#{1,6} ", re.MULTILINE)

It requires the #s at column zero and a single literal space after them. But the
persisted format promises more than that — deferred-work-format.md:53 says an entry
ends at "any other # .. ###### heading" — and CommonMark counts three shapes
this regex does not: up to three leading spaces, a tab as the separator, and an empty
heading with nothing after the #s.

When the boundary is missed, the entry span runs on past the section header, and
everything below is read as part of the preceding entry.

Repro

A ledger whose open DW-1 is followed by a section heading and then a column-zero
gate: 3-2:

### DW-1: an unlanded entry
status: open
summary: s
evidence: e

<HEADING>

gate: 3-2

[tok for e in parse_ledger(text) for tok in gates(e).tokens]:

<HEADING> result
## Notes [] control — bounded correctly
## Notes ['3-2'] absorbed
## Notes ['3-2'] absorbed
## Notes ['3-2'] absorbed
## Notes ['3-2'] correct — 4 spaces is an indented code block, not a heading
##\tNotes ['3-2'] absorbed
## ['3-2'] absorbed — valid empty ATX heading
## [] bounded
## Notes ## [] bounded

The column-zero row is the control that proves the repro is not vacuous, and the
4-space row is the control in the other direction: it must keep absorbing, because
CommonMark makes that an indented code block rather than a heading. Any fix has to
leave both rows unchanged.

Consequence

DW-1 is open, so the absorbed gate: 3-2 makes it gate story 3-2. Validation and
dispatch both then refuse a story that, read against the documented format, that entry
never gated. It fails closed rather than open, but it is a spurious hard pause on the
run, and the reason names an entry the operator will not find a gate in.

Scope

Pre-existing and not introduced by #502 — the regex is byte-identical on main
(deferredwork.py:24 there) and that PR does not touch it. What #502 changes is the
severity: before the gate: field, a mis-bounded span mis-attributed closes: or a
status read; now it can block dispatch.

Worth noting the two halves of the module already disagree — _LINE_HEADING_RE
(deferredwork.py:944, the legacy side) is ^(#{1,6})[ \t]+(.*?)[ \t]*$, so it accepts
a tab separator that the canonical side rejects.

No test pins the current column-zero/space-only shape, so tightening the regex is not
blocked by an existing expectation.

Fix shape

Allow {0,3} of leading indentation and [ \t] as the separator, and admit the empty
heading — while keeping the 4-space row absorbed. Sibling parser-scope defect: #327.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions