Skip to content

fix(add): stop inserting a task between the previous task's title and its body - #49

Merged
fohara merged 1 commit into
mainfrom
fix/48-add-steals-task-body
Aug 10, 2026
Merged

fohara merged 1 commit into
mainfrom
fix/48-add-steals-task-body

Conversation

@fohara

@fohara fohara commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

Closes #48.

The bug

lash add -f <file> put the new - [ ] line directly after the last task's title instead of after that task's whole entry. The parser records a task's checkbox line and its annotation lines and nothing else, so a free-text body underneath — prose, numbered steps, acceptance criteria, indented note bullets — was invisible when the insertion point was computed.

The previous task kept its title and lost its body; the new task inherited lines it had nothing to do with. Markdown is the source of truth, so the file itself was wrong for whoever read it next. Nothing surfaced it: lash lint passes on the damaged file, and lash show prints ID, title, status, file and labels but never the body. Present since 0.1.0.

The fix

Resolved the same way EndOfTasksSection was — the source text is the only thing that knows where a task's block ends, so the answer is computed in the emitter, which is already holding the file. InsertAnchor::AfterTaskBlock(line) carries "start looking here" through from the resolver, and the emitter walks forward past every line that continues the block (indented, not a checkbox) before inserting.

Blank lines count as part of the block only when indented content resumes after them, so a body split into paragraphs stays whole while a blank line that genuinely ends the block still stops the walk.

Deriving this from the parsed model instead would have meant a new field on Task, a fourth thing to keep in sync, and the same class of bug the launch-blocker sweep already fixed twice.

Bodyless tasks resolve to exactly the line they did before, so the common case is untouched.

One formatting change

A new task inserted directly below another task's body now gets a blank line above it, matching the issue's expected output — butting it against the tail of someone else's prose reads as more of that prose, which is the confusion the insertion point exists to avoid. Tasks with no body still sit flush against each other.

Verification

The issue's repro script now produces its stated expected result exactly, and the reported line number matches where the task actually landed.

Tests added:

  • the reported repro end to end, asserting the body stays attached and the reported line agrees with the file
  • a body split into paragraphs by a blank line
  • indented contextual note bullets
  • unit coverage of the anchor walk, including the cases that must still stop it (next checkbox, unindented line, blank line that ends the block) and that bodyless tasks resolve unchanged

Full workspace suite, clippy and rustfmt are clean.

Found while fixing, filed not folded in

lash format deletes task bodies outright — same root cause. #44 stopped format from deleting whole sections, but the ## Tasks span is still regenerated from the model, and the model has no body to regenerate. A task with prose under it comes back as a bare checkbox line, exit code 0. Fixing that needs TaskFile to carry the body lines the parser drops, which is a larger change than this one. Tracked in lash.index.md; the lint rule suggested on #48 is worth revisiting once the model carries bodies, since until then the linter has nothing to check a body against.

… its body

The parser records a task's checkbox and annotation lines but not its
free-text body, so the emitter now walks the source past the whole block
before inserting. Closes #48.
@fohara
fohara merged commit f10b3bd into main Aug 10, 2026
21 checks passed
@fohara
fohara deleted the fix/48-add-steals-task-body branch August 10, 2026 16:50
fohara added a commit that referenced this pull request Aug 11, 2026
…nalysis (#52)

Found by mutation-testing lash 0.3.1 with Flawd 0.8.0 (400 mutants sampled
from 3528, per-test targeting, every survivor re-confirmed against the full
suite). These eight survivors are all cases where a conditional's two branches
produce the same test outcome, so inverting it changed nothing that any test
observed.

Three sit in code 0.3.1 just changed:

- formatter: `normalize_whitespace` was only ever exercised through the helper
  it gates, never through the flag itself, so inverting the flag passed. The
  trailing-blank-line trim and the "don't re-emit a description that is
  already in the source" guard were likewise unasserted.
- parser: a blank line between a checkbox and its annotation block. The parsed
  values did not pin this — annotations are recovered downstream either way —
  but `annotation_line_count` silently drops to 0, and that count is what the
  write paths use to find where a task's annotations end. A wrong count there
  is how `lash add` splices a task into a previous task's block, the data-loss
  class fixed in 0.3.1 (#49).
- blocker analysis: `find_blocker_chains` was only tested on a chain one hop
  deep, where the walk exits on the "current task is not itself blocked" check
  before its own progress guard is ever what stops it. A four-node chain
  distinguishes them. The report's per-blocker status word and its chains
  section were also unpinned.

Each test was verified to fail against its mutation and pass without it.

No production code changed.

One further survivor is left deliberately untested: in the parser's blank-line
branch, `} else if !annotation_lines.is_empty() { break; }` is followed by an
unconditional `break`, so both paths break identically and no test can observe
the difference. It is an equivalent mutant, not a gap; the redundant branch
could be removed but that is a change to production code and not this commit's
business.
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.

lash add -f inserts the new task line between the previous task's title and its body

1 participant