Skip to content

feat: heading-duplicate — flag identical headings that silently break anchor linking - #10

Merged
HetCreep merged 5 commits into
TheColliery:mainfrom
mehvetero:feat/heading-duplicate
Jul 28, 2026
Merged

feat: heading-duplicate — flag identical headings that silently break anchor linking#10
HetCreep merged 5 commits into
TheColliery:mainfrom
mehvetero:feat/heading-duplicate

Conversation

@mehvetero

Copy link
Copy Markdown
Member

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 -1 appended to its slug. A link to #slug silently 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 #slug link 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 that anchor-missing catches, except the link resolves (to the wrong place) instead of breaking visibly.

Implementation

  • Tracks heading slugs in the existing heading walk — no second AST pass
  • Compares lowercase-trimmed text via safeDecode (same normalization the anchor resolver uses)
  • Reports the second occurrence with a pointer to the first's line number

Fixture changes

  • defects-structure.md: planted ## Setup duplicate (L29 + L33)
  • decoy-clean.md: the existing ## 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 — #repeated-heading still resolves to the one remaining heading.

Test changes

  • defects-structure: added heading-duplicate@33 to expected findings (12 total, was 11)
  • dup-heading-anchor inline test: now expects 2 findings (heading-duplicate + anchor-missing) instead of 1. Both assertions check by check id, not just count.
136/136 pass, 0 fail

…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.
@HetCreep

Copy link
Copy Markdown
Member

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 doc-structure's existing heading walk, no second pass), the safeDecode reuse matches the anchor resolver, and preserving the decoy fixture's anti-cry-wolf property shows you read the room. The dist rebuild in the second commit was the right call too — all gates are green.

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: ### Added / ### Fixed repeat per release by design of the world-standard format). That is the cry-wolf shape this suite's own rules ban — a check that flags correct files trains readers to ignore it.

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:

  1. Escalation when the ambiguity is live: if a link in the same document targets the duplicated slug (#setup while two ## Setup exist), that one is CONFIRMED-grade regardless of siblinghood — the wrong-target isn't theoretical there. You already have the link walk in the same file.
  2. Fixture: add a changelog-shaped clean case (## 1.0.0/### Added · ## 2.0.0/### Added) so the anti-cry-wolf property is pinned by a test, not by review.

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.

@HetCreep

Copy link
Copy Markdown
Member

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 id (the spec-level root: ids must be unique per document), reStructuredText auto-targets, AsciiDoc auto-ids, .ipynb heading anchors as GitHub/nbviewer render them. GitHub's -1 suffix is just markdown's instance of it.

Two cheap asks that follow, neither blocking:

  1. Phrase the check's doc comment format-neutrally — "duplicate headings make auto-generated anchors ambiguous (GitHub markdown: -1 suffix)" rather than defining the defect BY the GitHub behavior. Same check, honest scope.
  2. Keep the slug derivation isolated (it already nearly is — one safeDecode(...).trim().toLowerCase() expression) so a later format adapter reuses the duplicate-detection while swapping only the slugger. No multi-format work now — just don't fuse the two.

The siblings-only request from the previous comment stands unchanged.

@mehvetero

Copy link
Copy Markdown
Member Author

Adopted siblings-only as the default — the parent stack tracks the nearest heading one level up, so two ### Added under different ## versions share no parent and pass clean. Added a changelog-shaped section to the decoy (two releases, repeated subsection headings) to pin the anti-cry-wolf property by test.

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.

@mehvetero

Copy link
Copy Markdown
Member Author

Ok,now done.

@HetCreep

Copy link
Copy Markdown
Member

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

  • Our round-1 number was ~50× low. We said "3–6 duplicate heading-texts per CHANGELOG". We had grepped distinct texts; the engine fires per occurrence. Re-run at the pre-fix commit: 320 heading-duplicate findings across our 7 CHANGELOGs (CM 82 · CB 71 · CW 57 · CT 53 · CH 32 · CF 16 · CL 9). The conclusion we drew was right; the figure we handed you was not.
  • Your sync is better than we checked. We verified two engine homes; there are three, and plugin/skills/doc-structure/lib/ — the copy the file-copy install path actually runs — is the one we missed. Your fcb4710 had already rebuilt all three; all three blobs are identical at HEAD and verify.mjs's byte-gate enforces it. Nothing for you to do.

Verification we ran

At your HEAD 3be71b1: 0 heading-duplicate and 0 total findings across all 14 CHANGELOGs + READMEs. 136/136 tests green, verify.mjs PASS.

We also confirmed your decoy is a real regression guard rather than one that happens to pass: sabotaging parentKey to '' (the document-global regression) turns exactly 2 tests RED, including decoy-clean.md: ZERO findings. That is the anti-cry-wolf property pinned properly. Restored, 18/18 green.

F1 — MEDIUM: the parent is keyed by TEXT, not identity

scripts/lib/md-checks.mjs:159-162parentAt[depth] = slug and parentKey = parentAt[depth-1] || ''. Because the key is the parent's text, three cases misfire from one root (all probe-confirmed against your HEAD):

  • Nephew collision: ## Install / ### Windows / #### Steps plus ## Uninstall / ### Windows / #### Steps → fires duplicate heading "Steps" under the same parent. They are not under the same parent, so the message asserts something false.
  • Skip-level fallback: ## A / #### Deep plus ## B / #### DeepparentAt[3] is undefined, both key to '', cross-section false positive.
  • Cascade: a genuinely duplicated ## [1.0.0] correctly fires, but each ### Added child re-fires too. MD024 siblings_only flags only the parents.

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 parentKey from the nearest defined shallower ancestor's id, walking depth-1 down to 1 (this also cures the skip-level case); then parentAt[depth] = thisId. We checked the consequences: every existing test and both decoys still hold (the changelog decoy's children hang off two different version nodes → distinct keys; ## Setup ×2 shares one parent node → still flagged), all three probes above go silent, and the version-duplicate true positive survives.

F2 — LOW: the message contradicts the engine's own anchor model

auto-generated anchors become ambiguous — but collectAnchors is documented as implementing GitHub dedupe order, and your own repurposed test is titled "duplicate headings resolve through GitHub dedupe suffixes". The anchors stay unique. The real harm is that a plain #slug link reaches only the first occurrence, and the suffixed anchors are order-fragile. Your original d342f23 wording said this; the 3be71b1 wording pass made it less accurate. Restoring the mechanism clause is enough — phrasing is yours.

F3 — LOW: safeDecode on heading TEXT is a category error

decodeURIComponent belongs to fragment resolution, not to heading text. Probe: ## Save 100%25 faster and ## Save 100% faster are flagged as duplicates although they render differently. Key on textContent(node).trim().toLowerCase() and drop the decode.

F4 — MEDIUM: the check ships undocumented in its own contract

skills/doc-structure/SKILL.md ## Checks (engine ids) (lines 28-39) lists every id except heading-duplicate. In a doc-health suite, the judging agent would receive an id its own contract never defines.

Please add the row, and state the siblings-only carve in it — something like "same-parent duplicates only; keep-a-changelog per-release ### Added repeats are deliberately not flagged" — so that engine silence on a changelog reads as design rather than as a miss. The frontmatter description has room (642/1024 chars) for the heading-hierarchy clause. A [Unreleased] ### Added CHANGELOG entry belongs here too; leave the version number alone, we press that at release. Do not touch CHANGELOG:98's launch-day "8 stable check ids" — that line is shipped history.

F5 — a known limit, your call

## Setup! and ## Setup both slug to setup but are not flagged, because the key is the text. Keeping the text key is defensible and matches markdownlint MD024. Since the message cites anchors as the rationale, just name the limit in the doc comment.

Checked and clean, so you are not chased for them

No downstream consumer switches on check ids (0 hits across hooks/commands/verify) · performance is O(headings) · the parser is untouched in all three homes, so no CodeQL dismissal resurrects · Thai/CJK unaffected, the Thai decoy is green · blockquote-heading treatment is consistent with the existing checks' shared walk · no version bump in the PR, which is correct.

F1 and F4 are what we would like before merge. F2, F3 and F5 are small and we would take the PR with them noted. The check itself is a good addition — the corpus evidence above is the argument for it.

@mehvetero

Copy link
Copy Markdown
Member Author

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:

  • nephew (## Install/### Windows/#### Steps + ## Uninstall/### Windows/#### Steps): 0 findings
  • skip-level (## A/#### Deep + ## B/#### Deep): 0 findings
  • cascade (## 1.0.0 ×2 with ### Added children): parent fires once, children silent

True positive (## Setup ×2) and changelog decoy both hold.

F2 — message restored: "a plain #slug link reaches only the first occurrence."

F3safeDecode dropped from the text key. "100%25 faster" and "100% faster" are no longer false duplicates.

F4heading-duplicate row added to skills/doc-structure/SKILL.md check table, siblings-only carve stated, changelog note included.

F5 — known limit documented in the engine doc comment.

136/136 pass, verify PASS, dist rebuilt.

@HetCreep
HetCreep merged commit 4f95617 into TheColliery:main Jul 28, 2026
11 checks passed
HetCreep added a commit that referenced this pull request Jul 28, 2026
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>
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.

2 participants