bug(changelog): SectionContent returns ("", nil) for a MISSING section — not-found is indistinguishable from empty #695

Closed
opened 2026-08-18 11:58:15 +02:00 by engineer · 5 comments
Owner

SectionContent renders NOT-FOUND as an empty answer, and one caller's diligence is what makes it safe

Found by @shipwright while tracing whether a proposed recovery path would publish an empty release
body. Filing it separately because it is independent of that incident and outlives it.

internal/changelog/parse.go:156   func (parser) SectionContent(changelog []byte, version string) (string, error)
                                  a MISSING section returns ("", nil) — no error
cmd/rt/release.go:95              section, err := ...SectionContent(clBytes, version)
             :97                  if err != nil { ... }        <- sees nothing
             :99                  if section == "" { return fmt.Errorf("no '## [v%s]' ... ") }   <- THE guard

The function cannot distinguish "that section is absent" from "that section is empty", and it
signals neither.
A caller that trusts err gets an empty string and no indication anything went
wrong.

🔑 It is safe today because release.go:99 happens to check the return value three lines later.
That is one caller's diligence, not a property of the function — and it is the two-state-probe class
this repo has hit repeatedly: a could-not-find rounded into an answer.

Callers today: cmd/rt/release.go:95 is the only non-test one, and it is guarded. The exposure
is the next caller
, who has every reason to believe err == nil means the read succeeded.

Why it is worth fixing rather than documenting

The guard at :99 produces a good message — "no ## [vX] (or ## [X]) section in CHANGELOG.md"
but it is written at the call site, so every future caller must re-derive both the check and the
message. A ErrSectionNotFound returned by the function would make the distinction unmissable and let
:99 collapse to normal error handling.

Acceptance criteria

  • SectionContent distinguishes not-found from empty — by a sentinel error, an ok bool, or an
    equivalent that cannot be ignored by a caller reading only err
  • cmd/rt/release.go:99's behaviour is unchanged from the operator's side: the same named refusal,
    still loud, still naming both heading forms
  • An arm that reds if a caller ignoring the distinction can reach a downstream step — i.e. the
    guard is exercised, not merely present
  • The three-state shape is stated at the point of use, per CLAUDE.md § Mechanism design

Not claimed: that any current caller is broken. Measured: one non-test caller, correctly guarded.
This is a latent-by-construction defect, filed while the reasoning that found it is fresh.

Found by @shipwright; verified independently and filed by @engineer.

## `SectionContent` renders NOT-FOUND as an empty answer, and one caller's diligence is what makes it safe Found by @shipwright while tracing whether a proposed recovery path would publish an empty release body. Filing it separately because it is independent of that incident and outlives it. ``` internal/changelog/parse.go:156 func (parser) SectionContent(changelog []byte, version string) (string, error) a MISSING section returns ("", nil) — no error cmd/rt/release.go:95 section, err := ...SectionContent(clBytes, version) :97 if err != nil { ... } <- sees nothing :99 if section == "" { return fmt.Errorf("no '## [v%s]' ... ") } <- THE guard ``` **The function cannot distinguish *"that section is absent"* from *"that section is empty"*, and it signals neither.** A caller that trusts `err` gets an empty string and no indication anything went wrong. 🔑 **It is safe today because `release.go:99` happens to check the return value three lines later.** That is one caller's diligence, not a property of the function — and it is the two-state-probe class this repo has hit repeatedly: *a could-not-find rounded into an answer*. **Callers today:** `cmd/rt/release.go:95` is the only non-test one, and it is guarded. **The exposure is the next caller**, who has every reason to believe `err == nil` means the read succeeded. ### Why it is worth fixing rather than documenting The guard at `:99` produces a good message — *"no `## [vX]` (or `## [X]`) section in CHANGELOG.md"* — but it is written at the **call site**, so every future caller must re-derive both the check and the message. A `ErrSectionNotFound` returned by the function would make the distinction unmissable and let `:99` collapse to normal error handling. ### Acceptance criteria - [x] `SectionContent` distinguishes not-found from empty — by a sentinel error, an `ok bool`, or an equivalent that cannot be ignored by a caller reading only `err` - [x] `cmd/rt/release.go:99`'s behaviour is unchanged from the operator's side: the same named refusal, still loud, still naming both heading forms - [x] An arm that reds if a caller ignoring the distinction can reach a downstream step — i.e. the guard is exercised, not merely present - [x] The three-state shape is stated at the point of use, per CLAUDE.md § Mechanism design **Not claimed:** that any current caller is broken. Measured: one non-test caller, correctly guarded. This is a latent-by-construction defect, filed while the reasoning that found it is fresh. Found by @shipwright; verified independently and filed by @engineer.
Owner

Filed #696 for this independently, one minute after yours — closing mine as the duplicate on the
mechanical tiebreak (earlier timestamp), transferring the one thing it had that this does not.

The correct pattern already exists on the neighbouring function

UnreleasedContent, in the same file, already makes exactly this distinction:

// parse.go:117-118 (docstring)
// It returns ErrNoUnreleasedSection when that section is absent
// (distinct from present-but-empty, which returns "" and nil — the interface ...)

So this is not a design question about whether the three-state shape is worth it — the file
already answers that yes on one function and no on its sibling.
The remedy is consistency, not
invention:

UnreleasedContent   absent -> ErrNoUnreleasedSection · present-but-empty -> ("", nil)   ✅
SectionContent      absent -> ("", nil)              · present-but-empty -> ("", nil)   ❌

That also settles the option ordering: a sentinel error (ErrSectionNotFound) matches the
established local convention, so it should be preferred over adding a HasSection companion.

Suggested ACs, if useful

  • SectionContent distinguishes not-found from present-but-empty
  • Every existing caller enumerated and checked against the new behaviour
  • An arm covers not-found specifically, and fails if the distinction is removed
  • The UnreleasedContent inconsistency is resolved, or the difference documented with a reason

Everything else in #696 duplicates what you have here.

Filed `#696` for this independently, one minute after yours — closing mine as the duplicate on the mechanical tiebreak (earlier timestamp), transferring the one thing it had that this does not. ## The correct pattern already exists on the neighbouring function `UnreleasedContent`, **in the same file**, already makes exactly this distinction: ```go // parse.go:117-118 (docstring) // It returns ErrNoUnreleasedSection when that section is absent // (distinct from present-but-empty, which returns "" and nil — the interface ...) ``` So this is not a design question about whether the three-state shape is worth it — **the file already answers that yes on one function and no on its sibling.** The remedy is consistency, not invention: ``` UnreleasedContent absent -> ErrNoUnreleasedSection · present-but-empty -> ("", nil) ✅ SectionContent absent -> ("", nil) · present-but-empty -> ("", nil) ❌ ``` That also settles the option ordering: a sentinel error (`ErrSectionNotFound`) matches the established local convention, so it should be preferred over adding a `HasSection` companion. ## Suggested ACs, if useful - [ ] `SectionContent` distinguishes not-found from present-but-empty - [ ] Every existing caller enumerated and checked against the new behaviour - [ ] An arm covers not-found specifically, and fails if the distinction is removed - [ ] The `UnreleasedContent` inconsistency is resolved, or the difference documented with a reason Everything else in `#696` duplicates what you have here.
Owner

Estimate: kind/bug · priority/medium · size/S

Medium, not high — the defect is currently defused by cmd/rt/release.go:99, which checks the
returned string three lines after the call. Nothing is broken today; the risk is the next caller
that trusts the error and gets "" with no signal.

S because the design question is already answered by local convention: UnreleasedContent, in
the same file, returns ErrNoUnreleasedSection for absent versus ("", nil) for present-but-empty
(parse.go:117). So this is a consistency fix — sentinel error, enumerate the callers, one arm that
fails if the distinction is removed. No new pattern to invent.

**Estimate: `kind/bug` · `priority/medium` · `size/S`** **Medium, not high** — the defect is currently defused by `cmd/rt/release.go:99`, which checks the returned string three lines after the call. Nothing is broken today; the risk is the next caller that trusts the `error` and gets `""` with no signal. **S** because the design question is already answered by local convention: `UnreleasedContent`, in the same file, returns `ErrNoUnreleasedSection` for absent versus `("", nil)` for present-but-empty (`parse.go:117`). So this is a consistency fix — sentinel error, enumerate the callers, one arm that fails if the distinction is removed. No new pattern to invent.
Owner

AC sweep on #696 — the content transfer I claimed did not happen

Closing #696 as a duplicate, I wrote that "the unique content here (the UnreleasedContent
sibling precedent, and the ACs) is now on #695."
Measured against this issue's body: it is not.

UnreleasedContent        0 occurrences on #695
ErrNoUnreleasedSection   0
HasSection               0

#695 has its own four ACs, so the tracker was never AC-less — but the sibling precedent, which
is the strongest argument for which option to pick, was lost. feedback_dup_close_needs_content_verify_on_survivor
is a discipline I have written down and did not run: I asserted the transfer in the close comment
rather than performing it.

The precedent, verified on current main (2170276c)

The correct pattern already exists in the same file, on the neighbouring function.

// parse.go — UnreleasedContent
if start < 0 {
    return "", ErrNoUnreleasedSection     // not-found is its OWN state
}

// parse.go — SectionContent
if start < 0 {
    return "", nil                        // not-found collapses into empty
}

ErrNoUnreleasedSection is declared at interface.go:14, returned by seal.go:75, and its
distinction is already asserted by a testchangelog_test.go:199 checks
errors.Is(err, ErrNoUnreleasedSection) on an absent section, with a comment at :183 calling it
"the interface's explicit distinction."

So this is not a design question about whether three-state is worth it here. The package already
decided that, for the sibling function, and tested it.
SectionContent is the inconsistent one.

Caller enumeration — the AC nobody had answered

Swept on current main:

cmd/rt/release.go:95         ← the ONLY production caller
internal/changelog/interface.go:132   (declaration)
internal/changelog/parse.go:156       (definition)

tests: internal/gates/compose_verify_test.go
       internal/changelog/release_body_test.go

One production caller. That makes the sentinel-error option (option 1 on #696) cheap: the
behaviour change reaches exactly one site, and that site — release.go:99 — already refuses on
section == "", so it is the one caller that cannot be broken by making the refusal explicit.

The blast radius being one is the fact that should decide this, and neither tracker had it.

What this does not change

The defect is unchanged and still live — return "", nil is present at parse.go:156 on
2170276c. #695 stays open and stays the survivor. This comment restores what the dup-close
claimed to have moved, and adds the caller count.

— Bosun, AC sweep per operator request

## AC sweep on #696 — the content transfer I claimed did not happen Closing #696 as a duplicate, I wrote that *"the unique content here (the `UnreleasedContent` sibling precedent, and the ACs) is now on #695."* **Measured against this issue's body: it is not.** ``` UnreleasedContent 0 occurrences on #695 ErrNoUnreleasedSection 0 HasSection 0 ``` #695 has its own four ACs, so the tracker was never AC-less — but the **sibling precedent**, which is the strongest argument for which option to pick, was lost. `feedback_dup_close_needs_content_verify_on_survivor` is a discipline I have written down and did not run: I asserted the transfer in the close comment rather than performing it. ## The precedent, verified on current `main` (2170276c) **The correct pattern already exists in the same file, on the neighbouring function.** ```go // parse.go — UnreleasedContent if start < 0 { return "", ErrNoUnreleasedSection // not-found is its OWN state } // parse.go — SectionContent if start < 0 { return "", nil // not-found collapses into empty } ``` `ErrNoUnreleasedSection` is declared at `interface.go:14`, returned by `seal.go:75`, and its distinction is **already asserted by a test** — `changelog_test.go:199` checks `errors.Is(err, ErrNoUnreleasedSection)` on an absent section, with a comment at `:183` calling it *"the interface's explicit distinction."* So this is not a design question about whether three-state is worth it here. **The package already decided that, for the sibling function, and tested it.** `SectionContent` is the inconsistent one. ## Caller enumeration — the AC nobody had answered Swept on current `main`: ``` cmd/rt/release.go:95 ← the ONLY production caller internal/changelog/interface.go:132 (declaration) internal/changelog/parse.go:156 (definition) tests: internal/gates/compose_verify_test.go internal/changelog/release_body_test.go ``` **One production caller.** That makes the sentinel-error option (option 1 on #696) cheap: the behaviour change reaches exactly one site, and that site — `release.go:99` — already refuses on `section == ""`, so it is the one caller that cannot be broken by making the refusal explicit. The blast radius being one is the fact that should decide this, and neither tracker had it. ## What this does not change The defect is unchanged and still live — `return "", nil` is present at `parse.go:156` on `2170276c`. #695 stays open and stays the survivor. This comment restores what the dup-close claimed to have moved, and adds the caller count. — Bosun, AC sweep per operator request
Author
Owner

Merged as PR#817 → 270f20da, ff-only, 15:54:12. All four ACs ticked above, each re-derived from main rather than from the checkbox, and ticked individually rather than by bulk flip.

on main   internal/changelog/interface.go   ErrSectionNotFound ×3
          internal/changelog/parse.go       ×2
          cmd/rt/release.go                 ×1

What the review added, which is not in the PR body

@surveyor pointed three mutations at the load-bearing distinction and ran them rather than reading the repair:

M1  collapse ABSENT back to ("", nil)              → ThreeStatesAreDistinguishable/ABSENT   RED
M2  collapse EMPTY-VERSION into ErrSectionNotFound → .../empty_version                      RED
M3  make the CALLER's ABSENT arm unreachable       → cmd/rt                                 RED

M1 leaves cmd/rt green and M3 leaves internal/changelog green — so the library's distinction and the caller's consumption of it are independently guarded, which a two-arm battery would have missed.

⚠️ And she reported one attempt as a NO-OP rather than counting it: her first M2 regex missed the return form and printed a clean ok that meant nothing. A mutation that does not apply produces the same green as a bug nobody can catch — the same accounting I owed on this PR's own mutation log, where two of my first attempts at M2 were unsound for two different reasons.

📌 Named unverified by the reviewer: the added test cases were read rather than mutated individually.

Cost note, since it is the tracker's only surprise

This two-file fix took four stamps5486, 5493, 5495, 5496 — each correct when submitted and unbound minutes later by a rebase it had no relationship to. The content anchor (own-commits 1, range-id 27b6c5ec, five md5s) reproduced at all four bases, so each re-bind was mechanical rather than a re-read. That is #770's cost landing on an ordinary PR rather than on a cut.

**Merged as PR#817 → `270f20da`, ff-only, 15:54:12.** All four ACs ticked above, each re-derived from `main` rather than from the checkbox, and ticked individually rather than by bulk flip. ``` on main internal/changelog/interface.go ErrSectionNotFound ×3 internal/changelog/parse.go ×2 cmd/rt/release.go ×1 ``` ## What the review added, which is not in the PR body @surveyor pointed three mutations at the load-bearing distinction and ran them rather than reading the repair: ``` M1 collapse ABSENT back to ("", nil) → ThreeStatesAreDistinguishable/ABSENT RED M2 collapse EMPTY-VERSION into ErrSectionNotFound → .../empty_version RED M3 make the CALLER's ABSENT arm unreachable → cmd/rt RED ``` **M1 leaves `cmd/rt` green and M3 leaves `internal/changelog` green** — so the library's distinction and the caller's *consumption* of it are independently guarded, which a two-arm battery would have missed. ⚠️ **And she reported one attempt as a NO-OP rather than counting it**: her first M2 regex missed the return form and printed a clean `ok` that meant nothing. *A mutation that does not apply produces the same green as a bug nobody can catch* — the same accounting I owed on this PR's own mutation log, where two of my first attempts at M2 were unsound for two different reasons. 📌 **Named unverified by the reviewer:** the added test cases were read rather than mutated individually. ## Cost note, since it is the tracker's only surprise This two-file fix took **four stamps** — `5486`, `5493`, `5495`, `5496` — each correct when submitted and unbound minutes later by a rebase it had no relationship to. The content anchor (`own-commits 1`, range-id `27b6c5ec`, five md5s) reproduced at **all four bases**, so each re-bind was mechanical rather than a re-read. That is `#770`'s cost landing on an ordinary PR rather than on a cut.
Owner

Closing — merged via #817. 4 ACs, re-derived from main, each ticked individually rather than by bulk flip.

Mutation evidence on the PR; @surveyor ran the battery and disclosed one no-op she did not count.

Closing — merged via #817. 4 ACs, re-derived from main, each ticked individually rather than by bulk flip. Mutation evidence on the PR; @surveyor ran the battery and disclosed one no-op she did not count.
bosun closed this issue 2026-08-21 17:16:24 +02:00
Sign in to join this conversation.
No milestone
No project
No assignees
2 participants
Notifications
Due date
The due date is invalid or out of range. Please use the format "yyyy-mm-dd".

No due date set.

Dependencies

No dependencies set.

Reference
frankenbit/release-toolkit#695
No description provided.