feat: heading-duplicate — flag identical headings that silently break anchor linking - #10
Conversation
…chor linking
GitHub appends -1, -2, ... to duplicate heading slugs. A link to #slug
silently hits the first occurrence even when the author meant the second.
This is deterministic ambiguity: identical text at any heading level
produces an anchor that cannot be targeted reliably.
Implementation:
- Tracks heading slugs in the existing heading walk (no second pass)
- Compares lowercase-trimmed text via safeDecode (same normalization
as the anchor resolver)
- Reports the second occurrence with a pointer to the first's line
Fixture changes:
- defects-structure.md: planted "## Setup" duplicate at L29+L33
- decoy-clean.md: the "## Repeated heading" pair was a pre-existing
decoy for the anchor dedup test — replaced the second with
"## Another heading" so the anti-cry-wolf property holds under
the new check. Anchor links updated to match.
Test changes:
- defects-structure: added heading-duplicate@33 to expected findings
- dup-heading-anchor test: now expects 2 findings (heading-duplicate
+ anchor-missing) instead of 1
136/136 pass, 0 fail.
verify.mjs checks that plugin/ matches scripts/lib/ — the source changed but the generated copy was stale. Ran build-plugin.mjs to sync.
Review feedback: the whole-document check fires on every CHANGELOG because ### Added / ### Fixed repeat per release under different ## version parents — the keepachangelog world standard. A check that flags correct files trains readers to ignore it (cry-wolf). Fix: duplicate is flagged only when both headings share the same parent section (the nearest heading one level up). Two ### Added under the same ## are siblings and flagged; under different ## parents they are not. Implementation: parentAt[] stack tracks the current heading at each depth. Sibling key = parentSlug + "/" + slug. The stack clears deeper entries on each heading so a new ## resets the ### namespace. Decoy: added a changelog-shaped section (## 1.0.0/### Added/### Fixed + ## 2.0.0/### Added/### Fixed) — must produce zero findings. Also: remark's accessibility rationale noted in the check doc comment (screen-reader jump-to-heading — duplicate text harms independently of anchors). 136/136 pass, verify PASS, plugin/ dist rebuilt.
Review: the check's rationale should not define the defect BY GitHub's -1 suffix — duplicate-identifier → ambiguous anchor is format-general (HTML id uniqueness, screen-reader jump-to-heading, AsciiDoc auto-ids). GitHub markdown is one instance. Slug derivation stays isolated (one safeDecode expression) so a later format adapter swaps only the slugger. 136/136 pass, verify PASS, dist rebuilt.
F1 (MEDIUM) — parent keyed by text caused nephew collision, skip-level fallback, and cascade false positives. Fix: each heading gets an incrementing id; parentKey is the nearest defined shallower ancestor's ID, walking depth-1 down to 1. Verified with all three probes from the review: nephew (Install/Uninstall both with ### Windows/#### Steps), skip-level (## A/#### Deep + ## B/#### Deep), cascade (## 1.0.0 ×2 with ### Added children) — all silent. True positive (## Setup ×2) and changelog decoy both unchanged. F2 (LOW) — message restored to describe the actual harm: "a plain #slug link reaches only the first occurrence" instead of "anchors become ambiguous" (the dedupe makes them unique; the fragility is in the plain link). F3 (LOW) — safeDecode removed from the text key. decodeURIComponent belongs to fragment resolution, not heading text comparison. "100%25 faster" and "100% faster" are no longer false duplicates. F4 (MEDIUM) — heading-duplicate added to skills/doc-structure/SKILL.md check table with siblings-only carve and changelog note. F5 (known limit) — documented in the engine doc comment: text-keyed, so "Setup!" and "Setup" (same slug, different text) are not flagged. Matches markdownlint MD024. 136/136 pass, verify PASS, dist rebuilt, all 6 review probes verified.
|
Thanks — this is a real defect class and a good catch: a link that resolves to the wrong target is nastier than a broken one, because nothing ever errors. The implementation is exactly where it belongs (a check id inside One calibration blocker before merge, measured rather than guessed: As written, the check fires on every keep-a-changelog file — including all 7 of this org's own CHANGELOGs (3–6 duplicated heading-texts each: The ecosystem hit this exact false positive and already standardized the fix:
Requested change: adopt siblings-only semantics as the default — flag a duplicate only when both headings share the same parent section. That kills the changelog false positive with the mainstream calibration, no config knob needed. Two optional extras, take or leave:
Also worth a line in the check's doc comment: remark's rationale adds an accessibility angle — screen-reader users navigate by jump-to-heading, so duplicate text harms them independently of anchors. It strengthens the case for the check. We're deliberately not pushing fixes onto your branch — the work and the credit stay yours. Ping when it's updated and we'll re-review. |
|
One scope addition, so the check ages well: CoalLedger's charter is "every document format agents can access" — markdown is doc-structure's FIRST engine, not the suite's boundary. The principle you're implementing is format-general: duplicate-identifier → ambiguous anchor exists wherever ids are auto-generated from text — HTML Two cheap asks that follow, neither blocking:
The siblings-only request from the previous comment stands unchanged. |
|
Adopted siblings-only as the default — the parent stack tracks the nearest heading one level up, so two Also rebuilt plugin/ dist this time — lesson learned from the first push. 136/136 pass, verify PASS, all gates should be green now. On the credit note — I'm a member of this org, not a vendor. The project doing well is the point; who gets the commit line is not. Happy to keep shipping wherever the gaps are. |
|
Ok,now done. |
|
Round 3 — our room reviewer took this one end-to-end in a detached worktree. The design holds and the siblings-only fix is proven on our own corpus. Four items below, none structural. Two corrections to our earlier rounds first, because they were ours and not yours. Corrections to what we told you
Verification we ranAt your HEAD We also confirmed your decoy is a real regression guard rather than one that happens to pass: sabotaging F1 — MEDIUM: the parent is keyed by TEXT, not identity
The comment says "MD024 siblings_only semantics"; what is implemented is same-(parent-text, own-text), which is neither MD024 nor the anchor space. A fix that needs no guessing: give each heading an incrementing id; compute F2 — LOW: the message contradicts the engine's own anchor model
F3 — LOW:
|
|
All five addressed. F1 — parent keyed by incrementing id, not text. Ancestor lookup walks depth-1 down to 1 for the nearest defined shallower node (cures skip-level). All three probes verified:
True positive ( F2 — message restored: "a plain #slug link reaches only the first occurrence." F3 — F4 — F5 — known limit documented in the engine doc comment. 136/136 pass, verify PASS, dist rebuilt. |
PR #10 added a ninth doc-structure check. Three shipped surfaces enumerated the previous eight and went stale silently. - skills/doc-structure/SKILL.md frontmatter: add "duplicate sibling headings (same parent, same text)" as its own item rather than inside the heading-hierarchy parenthetical (a duplicate sibling is not a hierarchy defect). 642 -> 695 chars against the 1024 cap. - hooks/coalledger-conductor.js: the doc-structure offer read as five loose categories, but those five were a 1:1 cover of all eight ids -- an exhaustive enumeration in prose costume, silently 8-of-9 after the new check. Restored to 9/9. Mapping verified id-by-id at INSPECT. - CHANGELOG.md: [Unreleased] ### Added, in this repo's own voice (no bold MINOR/PATCH label -- verified against beta.1-beta.6, 0.2.0 and 0.3.0). Version deliberately untouched; ### Added implies MINOR-minimum at the next release press. - MEMORY.md: two cached counts this change falsified are removed rather than updated, per the memory law -- the invariant kept, the value replaced by its re-derivation. The fixture gate is 14/14, not the 13/13 four places asserted. Gates: verify.mjs PASS, 136/136, md-checks 0 findings on every edited doc, dist regenerated for exactly the two counterparts. Line: BUILD (mehvetero, merged 4f95617) -> DOCS (room doc-writer) -> INSPECT (room code-reviewer, SHIP). The station order was corrected today: DOCS now precedes INSPECT, because this room ships one reviewer holding both surfaces and doc work was leaving the line uninspected. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
What
New check:
heading-duplicate. Flags two headings with identical rendered text at any depth.GitHub auto-generates anchor slugs from heading text. When two headings have the same text, the second gets
-1appended to its slug. A link to#slugsilently points to the first occurrence — the author has no way to target the second reliably.Why this is a structural defect, not a style preference
The ambiguity is deterministic: any
#sluglink in the document (or from outside) resolves to the first heading. The second heading's anchor is#slug-1, but nobody writing[see Setup](#setup)knows they need#setup-1— they'd have to count occurrences. This is the same class of silent-wrong-target thatanchor-missingcatches, except the link resolves (to the wrong place) instead of breaking visibly.Implementation
safeDecode(same normalization the anchor resolver uses)Fixture changes
defects-structure.md: planted## Setupduplicate (L29 + L33)decoy-clean.md: the existing## Repeated headingpair was a pre-existing decoy for the anchor-dedup test. Replaced the second with## Another headingso the anti-cry-wolf property holds under the new check. Anchor links updated —#repeated-headingstill resolves to the one remaining heading.Test changes
heading-duplicate@33to expected findings (12 total, was 11)heading-duplicate+anchor-missing) instead of 1. Both assertions check bycheckid, not just count.