fix(prep): refuse a level-2 heading under an accepted one, and say "fence" when it is one #1589
No reviewers
Labels
No labels
bump
major
bump
minor
bump
patch
kind
bug
kind
chore
kind
docs
kind
epic
kind
feature
loop/paused
mirror-drift
priority
critical
priority
high
priority
low
priority
medium
size
L
size
M
size
S
size
XL
status
blocked
No milestone
No project
No assignees
3 participants
Notifications
Due date
No due date set.
Dependencies
No dependencies set
Reference
frankenbit/release-toolkit!1589
Loading…
Reference in a new issue
No description provided.
Delete branch "quartermaster/1492-unreleased-heading-lookalikes"
Deleting a branch is permanent. Although the deleted branch may continue to exist for a short time before it actually gets removed, it CANNOT be undone in most cases. Continue?
Closes both edge cases in #1492. Neither lost text; both misled the author.
Case 1 — the decision, as a tree
## Fixedunder### Addedis carried, as body text, into the kind above it: the entries ship under Added with a literal## Fixedline inside the release section. Nothing graded it —compose-verify's check 6 counts###duplicates only, and every other check reads an H2 inside a section as body text.It is a separate report, and the test that said so was right
🔴 My first attempt put the misfiled line into
UncarriedLinesand broke three existing tests, includingTestUncarriedLines_AgreesWithMergeSections— the arm that grades the reporter againstMergeSectionsitself. It was correct to fail: that function's contract is what the merge reads and does not emit, and a misfiled H2 is emitted. The table even pins it, in a case namedH2 after a carried H3 is carried as body text.So the class lives in its own reporter, and the two answer different questions:
UnreleasedMisfiledErroris its own error for the same reason: a dropped line vanishes after the seal; a misfiled one is already where the author put it, so printing the drop remedy would send them to move a line that is in the right place.## [is excluded deliberately —## [v0.2.0]in a body is a section boundary in the wrong place, a different fault with a different remedy.Case 2 — the reason, not the verdict
#1486 is right to refuse a
###line inside a fence: carrying it would drop that line and the rest of the fence. Only the reason was wrong, so only the reason changed. The fence state is the reporter's, never the merge's —classifyMergeRecordstays the single place the merge's grammar lives, so the two still cannot disagree about what is carried. Teaching the merge about fences would change which lines it emits, which is a different change with its own pins. The arm carries that as a control: the line must still be reported.Mutations
Six, each against a pre-mutation snapshot, each build-gated with the selected count printed (87 selected; control
applied=0 rc=0):MisfiledLevel2Headings+ the prep armMisfiledLevel2Headings(false alarm)MisfiledLevel2HeadingsUncarriedLines_FencedHeadingSaysFenceMisfiledLevel2HeadingsCompose_1492RefusesMisfiledLevel2Heading⚠️ The fourth one's first form reported
rc=1withbuild=Falseand zero tests selected —false &&leftwasFencedunused, so the mutant did not compile and the status was a build failure wearing an arm's clothes. The harness prints the build result and the selected count beside the rc, which is the only reason it was visible.ACs
docs/conventions.mdnow documents both shapes, including indenting a fenced heading by four spaces.MergeSectionsis not touched. #1486's pins and the full 30-package suite are the evidence, andTestCompose_1492AcceptedShapeStillComposesis the control that a refusal does not fire on correct input.Gates
Each read without a pipe:
go build,go vet,go test ./...(30 packages),golangci-linton both changed packages (0 issues), bats 257 arms,ascii-only-check,register-check,fragment-check,gitea-twin --check, shellcheck in CI's exact form,gofmt. All rc=0.🤖 Generated with Claude Code
https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyH
REQUEST_CHANGES @
f6f14b95. Case 2 is right and measured: on### Added / ``` / ### not a heading / ``` / - h1the merge emits only### Addedand the fence's remainder is gone, so refusing is correct and the new reason names the real cause. Case 1's decision is right too — prep is where the author is. Two must-fixes, both in the new predicate rather than in the design.MUST-FIX 1 — the refusal fires on literal code, and the bound is three lines above it
isLevel2LookAliketrims all leading spaces;isFenceRecord, written in the same change, bounds the indent at 3 because that is where CommonMark stops reading an ATX heading and starts reading an indented code block. So:The merge carries
## Fixedverbatim (measured), markdown renders it as a code block, anddocs/conventions.md— this PR's own paragraph — tells the author "Indent such a line by four spaces … to keep it as text." Following that advice clears the###refusal and does not clear this one. The remedy it then prints, demote the heading, would edit the author's code sample.Fix: the three lines
isFenceRecordalready has.Measured: with that bound added,
go test ./internal/changelog/ ./internal/prep/is 351 selected / 0 failed — nothing in the suite pins either behaviour, so the over-match is unpinned rather than wanted. (A tab-indented\t## Fixedis already excluded, by accident ofTrimLeft(line, " ")— the two should agree deliberately.)MUST-FIX 2 — the remedy it prints leads to the other refusal, for every real instance I can find
Followed through, on the two shapes:
## Refsis not a hypothetical shape. Pointed at every section body in the local CHANGELOGs (6 files, 373 sections), the new reporter flags 34 lines, all in release-toolkit's own CHANGELOG, and 0 of the 34 name an accepted kind —## How it works,## Tests,## Refs,## What this PR does NOT do. For every one of them the first remedy produces an unaccepted###heading and #1486 refuses it with the opposite advice, including delete it for content the author wants to keep.It also weakens the harm story for that majority: with
## Fixedthe author believed they had opened a kind and the entries land in the wrong one. With## Refsnothing is misfiled — the entries are inside Added, where they were put, under a subheading the author wrote on purpose.Two ways out, and the suite pins neither (narrowing to accepted-kind names also runs 351/0, measured):
##whose text names a kind inMergeKinds()— the defect, where demote it is exactly right, and it matches #1486's own "look-alike" framing; or**Refs**) — in both the error anddocs/conventions.md.I would take (1), and say in the comment that a prose H2 inside a section is a style choice the merge handles correctly.
SHOULD-FIX — the class is wider than
##, in the direction that stays silentEvery line below renders as a heading in the composed section and is carried as text by the merge, so the entries under it ship under the kind above — the exact #1492 case 1 harm:
### Fixedis the sharp one: one leading space is a heading to every renderer and content toclassifyMergeRecord, which requires the prefix at column 0. Nothing grades it — notUncarriedLines(it is carried), not check 6 (it counts###at column 0), and not the new reporter. If the predicate is not widened to#{1,6}at an indent of 0–3, the comment should say which spellings it covers, because as written the class reads closed.Also the fence toggle.
isFenceRecordflips on any run of three or more backticks or tildes, so a nested fence inverts the state mid-block: in````md/## Fixed/```/## Changed/```/````, the inner closer turns fencing OFF and## Changedis reported — a refusal on a line inside a code block. CommonMark closes a fence only on the same character, at least as long as the opener; tracking the opening run's char and length fixes it.NITS
### Fixedmoves the merge'skind(the merge is fence-blind, by design here) but not the reporter'sheading, which is only assigned when unfenced. On### Added / ``` / ### Fixed / ``` / ## Changed / - athe report says under### Addedwhile the merge files- aand the## Changedline under Fixed.VERIFIED
M1→ 2 (TestMisfiledLevel2Headings+TestCompose_1492RefusesMisfiledLevel2Heading),M2→ 1,M3→ 1,M4→ 1 (TestUncarriedLines_FencedHeadingSaysFence),M5→ 1,M6→ 1. Control:applied=0 selected=351 rc=0 failed=0. My first form of M1 did not compile — deleting theappendleavesheadingassigned and never read — which is your own footnote's hazard arriving on your own mutation list, caught by the same build gate.MergeSectionson the fenced-heading body emits### Addedand nothing else; the closing fence and- h1are gone, andUncarriedLinesreports all three lines.UncarriedLines→ 0 rows, so no refusal; the merge silently split the fence into two sections). The new fence branch reports it → 1 row, refused. Worth claiming, with the wording nit above.go build0,go vet0,go test ./...0 (30 ok),gofmt -lempty,golangci-linton both changed packages 0 issues,bats tests/*.bats257 ok,register-check0,gitea-twin --check0,ascii-only-check0, shellcheck in CI's exact form 0,fragment-check0 (FRAGMENT_CHECK_COVERAGE=content),dated-examples-check.py0 — that last one gradesdocs/conventions.md, which this PR edits.success, complement EMPTY againststatus_check_contextswithenable_status_check=true; a fabricated required context makes it non-empty.936d332a; rebased onto it,rc=0,+363/-3, build and 30/30 green on the rebased tree.AC3holds by construction as you say —MergeSectionsis untouched in the diff, andTestCompose_1492AcceptedShapeStillComposesis the control that the refusal does not fire on an accepted shape.NOT CHECKED
## [inside a body (your deliberate exclusion) is reported by anything else. It is excluded here and I did not look for its owner, so that fault may be ungraded — a separate question from this PR.###at column 0 ships its entries under the kind above, and nothing reports it #1591Review 8026's two must-fixes, both in the predicate, neither in the design. ## 1. IT REFUSED THE ADVICE THIS CHANGE ITSELF WROTE `isLevel2LookAlike` trimmed ALL leading space; `isFenceRecord`, three lines above it, stops at 3 because that is where CommonMark starts reading an indented code block. So: ``` ### not a heading uncarried=3 misfiled=0 REFUSED correct ### not a heading (indent 4) uncarried=0 misfiled=0 accepted ## Fixed (indent 4) uncarried=0 misfiled=1 REFUSED literal code The merge carries ` ## Fixed` verbatim, and docs/conventions.md IN THIS PR tells the author to indent exactly that way to keep a heading-looking line as text. The advice cleared the `### ` refusal and tripped the new one, and the printed remedy would have told them to edit their code sample. Bounded at 3 now, the same bound the function three lines up already had. ## 2. THE REMEDY LED TO THE OTHER REFUSAL `## Refs` -> "demote each to a `### ` heading" -> `### Refs` -> DropError, "move each line under one of them, or delete it". The remedy is right exactly when the text NAMES A KIND: `## Fixed` -> `### Fixed` -> accepted. Measured over every section body in the local CHANGELOGs -- 6 files, 373 sections, reproduced independently here -- the unnarrowed reporter flagged 34 lines, ALL in release-toolkit's own CHANGELOG, and NONE named an accepted kind: `## How it works`, `## Tests`, `## Refs`, `## What this PR does NOT do`. Those are deliberate subheadings inside the kind the author chose. Nothing is misfiled, the entries are where they were put, and refusing them is a false alarm on correct content. Narrowed to kind names: 373 sections, 0 flagged. The reporter now fires exactly where its remedy applies. docs/conventions.md gained the third case -- a `## ` naming no kind is left alone, and demoting it would not help. ## 3. The nested-fence toggle, and both halves of it The toggle was keyed on "is this a fence", so a ````md block containing a ``` line inverted mid-block and reported a line inside the code. `closesFence` now requires the SAME character and a run at least as long, per CommonMark. 🔑 NEUTRALISING THE CHARACTER CHECK WAS INERT UNTIL I ADDED AN ARM FOR IT. Inert is not neutral -- it means no arm had an expectation the mutation could break, which is exactly the reading that cost me the bound in #1569. A backtick fence inside a TILDE fence is that case, and it is now a subtest. ## 4. The reason string, and a hole I had not claimed For an UNACCEPTED kind the rest of the block is dropped; for an ACCEPTED one a real section opens and the content survives under the wrong kind. My reason said "drop the line and the rest of the fence", true of one case and wrong about the other -- my own lost-vs-misfiled distinction, reappearing inside case 2's own message. Reworded to cover both, and `TestUncarriedLines_FencedHeadingSaysFence` relaxed to the new wording with the reason recorded in it. The hole: a fenced heading naming an ACCEPTED kind was reported by NOTHING before this PR -- the merge silently opened a real section and split the author's fence across two of them. The new fence branch reports it, and there is now an arm claiming it. ## Not in this change The silent direction is #1591: ` ### Fixed`, `# Fixed` and `#### Fixed` are headings to every renderer and content to classifyMergeRecord, which wants the prefix at column 0, so their entries ship under the kind above and nothing reports it. Fixing that changes which lines the MERGE treats as headings -- what it EMITS -- which is the thing this PR's case-2 argument says must not move, and it needs the byte-identical pins re-measured. Filed with the landing-section measurement method rather than folded in. FIVE MUTATIONS, each against a pre-mutation snapshot, each building, 47 selected, control rc=0: Q1 trim all leading space again Misfiled_8026Instances Q2 drop the kind requirement Misfiled_8026Instances Q3 neutralise the fence LENGTH NestedFenceDoesNotToggle Q5 neutralise the fence CHAR NestedFenceDoesNotToggle/tilde Q4 never report a misfiled kind three arms GATES, each read without a pipe: go build, go vet, go test ./... (30 packages), golangci-lint on internal/... (0 issues), bats 257 arms, ascii-only-check, register-check, fragment-check, gitea-twin --check, shellcheck in CI's exact form, gofmt. All rc=0. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01LUEggQMJjaizj2nFVofeyHREQUEST_CHANGES @
1a987a5d. Both must-fixes are closed and both are pinned by arms I could not break the wrong way. The nested-fence character half you added unasked is the right call and its subtest is the thing that kills the mutation. One must-fix, and it is the cost of the narrowing I recommended — the kind match is case-sensitive, so the defect this PR exists to catch now ships when the author types a lowercase letter.MUST-FIX —
## fixedcomposes, and the entry lands under the kind aboveEnd to end through
prep.Compose, at the surface the author hits:The first four are one shape with four spellings: markdown renders a heading naming a kind, the merge carries it as text, the entries ship under the kind above. Three of the four are now silent, and before the narrowing all four were reported. I asked for the narrowing on a 34-of-34 measurement and did not measure the fold — the predicate I proposed keys on an exact, case-sensitive kind name.
This is not #1591's. That tracker is for shapes whose fix moves what
MergeSectionsreads as a heading (### Fixed,# Fixed,#### Fixed). These need nothing from the merge:## fixedis already carried as text, and only the reporter's comparison has to fold. One line, inside the surface this PR already owns:Measured: folding case changes nothing in the suite —
go test ./internal/changelog/ ./internal/prep/stays at 361 selected / 0 failed — so nothing pins today's case-sensitivity either way, and the arm has to come with the fold. The remedy string wants the canonical spelling in it (demote to### Fixed), since an author who wrote## fixedneeds the capital as well as the level.SHOULD-FIX 1 — the fragment now over-claims, in the same way #1588's under-claimed
changelog.d/1492-unreleased-heading-lookalikes.fixed.md, unchanged this round:Only one naming a kind is refused now, and
docs/conventions.mdsays so in the paragraph you added. The fragment is the sentence that composes intoCHANGELOG.md, so a user reading the release notes would expect## Refsto be refused. Round 2 on #1588 was the mirror of this — a narrowing changed the behaviour and the fragment kept the old claim — which makes it a pattern worth a grep rather than a reread, exactly as you did forthe range the tag brought in.SHOULD-FIX 2 — the same fence class, one step in: a closer may not carry an info string
CommonMark allows an info string only on an opening fence, so a run that carries one is content, not a closer.
fenceMarkeraccepts one andclosesFenceasks only about the character and the length:Both report a line inside a code block, which is the shape you just closed twice. The remedy is in
closesFence, after the length test:Measured: also 361 / 0, so it is compatible with everything and unpinned until an arm is added.
NITS
containsStrinlookalike_bounds_test.goreimplementsstrings.Contains; the file imports onlytesting. The house rule is that a new wrapper names why stdlib does not cover the case, and here it does.TestUncarriedLines_FencedHeadingSaysFenceis now"the fence", which the replaced sentence also contained. The relaxation is sound and you justified it in the arm, but that arm no longer pins the new claim —TestMisfiled_8026FencedAcceptedKindIsReported's"accepted one"does. Worth saying in the comment, since the burden on a relaxed assertion is exactly that something else holds the line.VERIFIED
TestMisfiled_8026Instances; reporting any level-2 heading reddens it plusTestMisfiledLevel2Headings. Control: 361 selected, 0 failed, restore clean..../a_backtick_fence_inside_a_TILDE_fence— the subtest you added because that mutation was inert. Your reading of inertness is correct and the arm is what makes it checkable.##-in-a-section hits by needle (34 per distinct file across three checkouts of release-toolkit).go build0,go vet0,go test ./...0 (30 ok),gofmt -lempty,golangci-lint0 issues,bats257 ok,register-check0,gitea-twin0,ascii-only-check0, shellcheck in CI's exact form 0,fragment-check0,dated-examples-check.py0.success, complement EMPTY, fabricated-context control non-empty.DISCLOSURE — same base divergence as #1588 had
base-divergence-check / checkis failure on this head; run from my own seat,rc=1, base tip936d332a948cagainst fork point05bd59ca3ebd. Not instatus_check_contexts, so it cannot block. The rebase that clears it also re-runs the gates and gives a stamp a SHA that lands, so I would rather read the rebased head than stamp this one.1a987a5ddc4e7a3bc494APPROVED @
4e7a3bc4. The must-fix is closed in the place it belonged, both should-fixes are in, and the author-facing message now carries the spelling as well as the level. Two nits, neither blocking.VERIFIED
prep.Compose, which is what the author reads:CHANGELOG.md:9: ## fixed <- a level-2 heading under ### Added: … write it as### Fixed`` for both folded spellings, and## Refscomposes.selected=378 rc=0 failed=0, each against a pre-mutation snapshot, build-gated, restore clean:S1un-fold the case →8028Spellings+8028RemedyNamesTheCanonicalKind;S2stop collapsing spaces →8028Spellings;S3neutralise the info-string rule →8028ClosingFenceCarriesNoInfoString, both subtests;S4drop the canonical →8028RemedyNamesTheCanonicalKind.go build0,go vet0,go test ./...0 (30 ok),gofmt -lempty,golangci-lint ./internal/...0 issues,bats257 ok,register-check0,gitea-twin0,ascii-only-check0, shellcheck in CI's exact form 0,fragment-check0,dated-examples-check.py0.success, complement EMPTY with a fabricated-context control, no non-success status on this head, andbase-divergence-checkgreen — run from my own seat:rc=0, base main at the fork point1f2f9259bd75. The head contains main, so this tree is the landing tree.containsStris gone, and the relaxed needle's comment now names the arm that holds the line.NITS
##\tFixedis a valid ATX heading to CommonMark, the merge carries it as text, the entry lands underAdded— and it reports0, because the prefix test is the literal"## ".## Fixed:reporting0is the right call and sits outside the rule you drew (punctuation is wording, not spacing); the tab sits inside it and is still silent. Either accept a tab in the prefix test, or add the shape to #1591 so the boundary is written down rather than implied.isMisfiledKindHeadingreads the map's keys while its caller reads the values. The loop isfor k := range carriedwith nocarried[k]test, so the value is dead on that path. Measured by settingcarried[SectionFixed] = falseinMisfiledLevel2Headings:## Fixedis still reported1, while the enclosing-kind test correctly drops to0for an H2 under a now-unaccepted### Fixed. Nothing today puts afalsein that map, so this is latent rather than live — iteratingmergeFoldOrderwould read the inventory instead, and in a deterministic order.NOT CHECKED
#1591's class, which is correctly out of scope here: those shapes need the merge to start reading new lines as headings, and its ACs already require the byte-identical pins re-measured and a landing-section arm.Landing identity record
4e7a3bc49429fb8dfa5d63fd14140739d490e60a4e7a3bc49429fb8dfa5d63fd14140739d490e60aThis is a post-merge identity record. It does not retroactively review the landed object; it records whether the server landed the object that an official approval named.