Skip to content

Stop lash format deleting sections and duplicating labels - #44

Merged
fohara merged 1 commit into
mainfrom
fix/format-destroys-sections
Aug 9, 2026
Merged

fohara merged 1 commit into
mainfrom
fix/format-destroys-sections

Conversation

@fohara

@fohara fohara commented Aug 9, 2026

Copy link
Copy Markdown
Contributor

Stacked on #41.

Two bugs, both of which made lash format unsafe to run on a file it did not
fully model. Found while checking that the empty-Tasks-section fix (#36) left
files lint-clean, and filed in lash.index.md in #43.

Sections were deleted

format_file rebuilt the file from the parsed model alone, and the model holds
the header, the Description section and the task tree and nothing else. Every
other section was silently deleted. A file with ## Notes and ## References
came back with only the header and the tasks, exit code 0, no warning.

Worse than the lash add bugs it was found next to, because format is
documented as a normalizer and the README tells people to run it. This repo's
own lash.index.md has a ## Description section, which sits before
## Tasks and is one of the two the model does hold, which is probably why
nobody hit it.

It also turned out broader than filed. A section above ## Tasks went too: a
## Background between the header and the tasks is folded by the parser into
"overview" text that TaskFile never stored and the formatter never emitted.

format_file now takes the source alongside the parsed file. It regenerates
only the spans it owns (the H1 plus annotation block, the Description section,
the Tasks section) and copies every other line through unchanged. Section
boundaries come from a new parser::header::section_span, which runs through
pulldown-cmark, so a ## inside a fenced code block neither opens nor closes a
section. Passing an empty source keeps the old model-only behaviour, which is
what the existing unit tests want.

Labels were duplicated

The parser records inline labels in metadata without removing them from the
title, and the formatter wrote both. Every run appended another copy:
- [ ] one #docs became #docs #docs, then #docs #docs #docs. Formatting
never reached a fixed point, so format --check reported the file as needing
formatting forever and the line grew without bound.

The formatter strips inline labels from the title and writes the sorted
metadata list as the only place they appear. The parsed title keeps them, so
lash list and search behave as they do today.

Idempotence

Each generated block ends with its own blank separator and the source's is
skipped rather than copied. Emitting both would add a blank line per run, which
is the same non-idempotence in a different coat.

Tests

  • Sections above and below ## Tasks survive, and keep their order.
  • A fenced ## Tasks inside a code block does not split a section.
  • Labels are emitted exactly once.
  • An already-canonical file comes back byte-identical.
  • An idempotence property test over six shapes asserting
    format(format(x)) == format(x), which is the test that catches both bugs at
    once.

All six were confirmed to fail against the old behaviour.

Also here

Three items in lash.index.md that were fixed in #34 and #35 but never ticked
off are marked done, with what was actually verified.

Base automatically changed from fix/add-reports-wrong-id to main August 9, 2026 16:16
Two bugs, both of which made `lash format` unsafe to run on a file it did
not fully model.

`format_file` rebuilt the file from the parsed model alone, and the model
holds the header, the Description section and the task tree and nothing
else. Every other section was silently deleted: a file with `## Notes`
and `## References` came back with only the header and the tasks, exit
code 0, no warning. This turned out to be broader than filed. A section
*above* `## Tasks` went too, because the parser folds it into "overview"
text that `TaskFile` never stored and the formatter never emitted.

`format_file` now takes the source alongside the parsed file. It
regenerates only the spans it owns — the H1 plus annotation block, the
Description section, the Tasks section — and copies every other line
through unchanged. Section boundaries come from
`parser::header::section_span`, which runs through pulldown-cmark, so a
`##` inside a fenced code block neither opens nor closes a section.
Passing an empty source keeps the old model-only behaviour, which is what
the existing unit tests want.

The second bug: the parser records inline labels in metadata without
removing them from the title, and the formatter wrote both. Every run
appended another copy of every label, so `- [ ] one #docs` became
`#docs #docs` and then `#docs #docs #docs`, and `format --check` reported
the file as needing formatting forever. The formatter strips inline
labels from the title and writes the sorted metadata list as the only
place they appear. The parsed title keeps them, so `lash list` and search
behave as they do today.

Each generated block ends with its own blank separator and the source's
is skipped rather than copied. Emitting both would add a blank line per
run, which is the same non-idempotence in a different coat.

Tests: sections above and below `## Tasks` survive and keep their order,
a fenced heading does not split a section, labels are emitted once, an
already-canonical file comes back byte-identical, and an idempotence
property test over six shapes asserting `format(format(x)) ==
format(x)`. All six fail against the old behaviour.

Also ticks off three items in lash.index.md that were fixed in #34 and
#35 but never marked done.
@fohara
fohara force-pushed the fix/format-destroys-sections branch from ccc55e3 to cff612f Compare August 9, 2026 16:22
@fohara
fohara merged commit db08504 into main Aug 9, 2026
21 checks passed
@fohara
fohara deleted the fix/format-destroys-sections branch August 9, 2026 16:33
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.

1 participant