From d342f2312d4b55043b73c8710bdd40075d4f4bc6 Mon Sep 17 00:00:00 2001 From: mehvetero <270047+mehvetero@users.noreply.github.com> Date: Tue, 28 Jul 2026 13:42:18 +0530 Subject: [PATCH 1/5] =?UTF-8?q?feat:=20heading-duplicate=20check=20?= =?UTF-8?q?=E2=80=94=20flag=20identical=20headings=20that=20break=20anchor?= =?UTF-8?q?=20linking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- scripts/fixtures/decoy-clean.md | 4 ++-- scripts/fixtures/defects-structure.md | 6 ++++++ scripts/lib/md-checks.mjs | 18 ++++++++++++++++++ scripts/lib/md-checks.test.mjs | 7 +++++-- 4 files changed, 31 insertions(+), 4 deletions(-) diff --git a/scripts/fixtures/decoy-clean.md b/scripts/fixtures/decoy-clean.md index bfb7e44..81bb966 100644 --- a/scripts/fixtures/decoy-clean.md +++ b/scripts/fixtures/decoy-clean.md @@ -19,9 +19,9 @@ https://in-fenced-code.example.com/fine-too ## Repeated heading -## Repeated heading +## Another heading -Anchors: [first](#repeated-heading) and [second](#repeated-heading-1) both resolve. +Anchors: [first](#repeated-heading) and [second](#another-heading) both resolve. Anchor via HTML: then [jump](#custom-anchor). diff --git a/scripts/fixtures/defects-structure.md b/scripts/fixtures/defects-structure.md index fbe9f1e..0bd49d5 100644 --- a/scripts/fixtures/defects-structure.md +++ b/scripts/fixtures/defects-structure.md @@ -25,3 +25,9 @@ See [broken ref][no-def] for details. Bare URL: https://example.com/dangling in prose. [orphan-def]: https://example.com/orphan + +## Setup + +Some instructions. + +## Setup diff --git a/scripts/lib/md-checks.mjs b/scripts/lib/md-checks.mjs index 84f4760..e730947 100644 --- a/scripts/lib/md-checks.mjs +++ b/scripts/lib/md-checks.mjs @@ -9,6 +9,9 @@ // Checks (ids are stable API): // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc +// heading-duplicate two headings with identical rendered text — GitHub +// appends -1/-2 to the slug, so #slug silently points +// to the first; a link to the second is wrong by default // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -126,6 +129,7 @@ export function checkDocument(src, opts = {}) { // ---- headings ------------------------------------------------------------- let prevDepth = 0; let h1Seen = false; + const headingSlugs = new Map(); // slug -> first node (for heading-duplicate) walk(root, (node) => { if (node.type !== 'heading') return; if (prevDepth && node.depth > prevDepth + 1) { @@ -136,6 +140,20 @@ export function checkDocument(src, opts = {}) { if (h1Seen) add('heading-multiple-h1', node, 'more than one top-level (h1) heading in this document'); h1Seen = true; } + // heading-duplicate: same rendered text at any depth — GitHub appends -1, + // -2, ... to the slug, so a link to #slug silently hits the FIRST one even + // when the author meant the second. Deterministic: identical text at any + // level is objectively ambiguous. + const text = textContent(node); + const slug = safeDecode(text).trim().toLowerCase(); + if (slug) { + const prev = headingSlugs.get(slug); + if (prev) { + add('heading-duplicate', node, `duplicate heading "${text}" (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); + } else { + headingSlugs.set(slug, node); + } + } }); // ---- link / image / definition targets ------------------------------------ diff --git a/scripts/lib/md-checks.test.mjs b/scripts/lib/md-checks.test.mjs index 81fef85..f2f080f 100644 --- a/scripts/lib/md-checks.test.mjs +++ b/scripts/lib/md-checks.test.mjs @@ -31,6 +31,7 @@ test('defects-structure.md: every planted defect found — exact check ids and l 'def-orphan@27', 'file-missing@11', // ./no-such-file.md 'file-missing@13', // dead image + 'heading-duplicate@33', // second "## Setup" — anchor silently points to first 'heading-multiple-h1@5', 'heading-skip@3', 'ref-undefined@23', @@ -100,8 +101,10 @@ test('bare-url is line-accurate inside a wrapped paragraph', () => { test('duplicate headings resolve through GitHub dedupe suffixes, and one-past fails', () => { const src = '## Dup\n\n## Dup\n\n[a](#dup) [b](#dup-1) [c](#dup-2)\n'; const findings = checkDocument(src); - assert.strictEqual(findings.length, 1); - assert.ok(findings[0].message.includes('#dup-2')); + // heading-duplicate fires on the second "## Dup" + anchor-missing on #dup-2 + assert.strictEqual(findings.length, 2); + assert.ok(findings.some(f => f.check === 'anchor-missing' && f.message.includes('#dup-2'))); + assert.ok(findings.some(f => f.check === 'heading-duplicate')); }); // --------------------------------------------------------------------------- From fcb471042df45b226bba0045ab11c7b42ad58421 Mon Sep 17 00:00:00 2001 From: mehvetero <270047+mehvetero@users.noreply.github.com> Date: Tue, 28 Jul 2026 14:22:41 +0530 Subject: [PATCH 2/5] build: regenerate plugin/ dist after heading-duplicate addition MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit verify.mjs checks that plugin/ matches scripts/lib/ — the source changed but the generated copy was stale. Ran build-plugin.mjs to sync. --- plugin/scripts/lib/md-checks.mjs | 18 ++++++++++++++++++ plugin/skills/doc-structure/lib/md-checks.mjs | 18 ++++++++++++++++++ 2 files changed, 36 insertions(+) diff --git a/plugin/scripts/lib/md-checks.mjs b/plugin/scripts/lib/md-checks.mjs index 84f4760..e730947 100644 --- a/plugin/scripts/lib/md-checks.mjs +++ b/plugin/scripts/lib/md-checks.mjs @@ -9,6 +9,9 @@ // Checks (ids are stable API): // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc +// heading-duplicate two headings with identical rendered text — GitHub +// appends -1/-2 to the slug, so #slug silently points +// to the first; a link to the second is wrong by default // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -126,6 +129,7 @@ export function checkDocument(src, opts = {}) { // ---- headings ------------------------------------------------------------- let prevDepth = 0; let h1Seen = false; + const headingSlugs = new Map(); // slug -> first node (for heading-duplicate) walk(root, (node) => { if (node.type !== 'heading') return; if (prevDepth && node.depth > prevDepth + 1) { @@ -136,6 +140,20 @@ export function checkDocument(src, opts = {}) { if (h1Seen) add('heading-multiple-h1', node, 'more than one top-level (h1) heading in this document'); h1Seen = true; } + // heading-duplicate: same rendered text at any depth — GitHub appends -1, + // -2, ... to the slug, so a link to #slug silently hits the FIRST one even + // when the author meant the second. Deterministic: identical text at any + // level is objectively ambiguous. + const text = textContent(node); + const slug = safeDecode(text).trim().toLowerCase(); + if (slug) { + const prev = headingSlugs.get(slug); + if (prev) { + add('heading-duplicate', node, `duplicate heading "${text}" (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); + } else { + headingSlugs.set(slug, node); + } + } }); // ---- link / image / definition targets ------------------------------------ diff --git a/plugin/skills/doc-structure/lib/md-checks.mjs b/plugin/skills/doc-structure/lib/md-checks.mjs index 84f4760..e730947 100644 --- a/plugin/skills/doc-structure/lib/md-checks.mjs +++ b/plugin/skills/doc-structure/lib/md-checks.mjs @@ -9,6 +9,9 @@ // Checks (ids are stable API): // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc +// heading-duplicate two headings with identical rendered text — GitHub +// appends -1/-2 to the slug, so #slug silently points +// to the first; a link to the second is wrong by default // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -126,6 +129,7 @@ export function checkDocument(src, opts = {}) { // ---- headings ------------------------------------------------------------- let prevDepth = 0; let h1Seen = false; + const headingSlugs = new Map(); // slug -> first node (for heading-duplicate) walk(root, (node) => { if (node.type !== 'heading') return; if (prevDepth && node.depth > prevDepth + 1) { @@ -136,6 +140,20 @@ export function checkDocument(src, opts = {}) { if (h1Seen) add('heading-multiple-h1', node, 'more than one top-level (h1) heading in this document'); h1Seen = true; } + // heading-duplicate: same rendered text at any depth — GitHub appends -1, + // -2, ... to the slug, so a link to #slug silently hits the FIRST one even + // when the author meant the second. Deterministic: identical text at any + // level is objectively ambiguous. + const text = textContent(node); + const slug = safeDecode(text).trim().toLowerCase(); + if (slug) { + const prev = headingSlugs.get(slug); + if (prev) { + add('heading-duplicate', node, `duplicate heading "${text}" (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); + } else { + headingSlugs.set(slug, node); + } + } }); // ---- link / image / definition targets ------------------------------------ From 8cad1f98a1e516543e6e83a91b5f34d3c695ef08 Mon Sep 17 00:00:00 2001 From: mehvetero <270047+mehvetero@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:18:33 +0530 Subject: [PATCH 3/5] fix: heading-duplicate adopts siblings-only semantics (MD024) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugin/scripts/lib/md-checks.mjs | 34 +++++++++++++------ plugin/skills/doc-structure/lib/md-checks.mjs | 34 +++++++++++++------ scripts/fixtures/decoy-clean.md | 20 +++++++++++ scripts/lib/md-checks.mjs | 34 +++++++++++++------ 4 files changed, 89 insertions(+), 33 deletions(-) diff --git a/plugin/scripts/lib/md-checks.mjs b/plugin/scripts/lib/md-checks.mjs index e730947..eec1ed4 100644 --- a/plugin/scripts/lib/md-checks.mjs +++ b/plugin/scripts/lib/md-checks.mjs @@ -9,9 +9,11 @@ // Checks (ids are stable API): // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc -// heading-duplicate two headings with identical rendered text — GitHub -// appends -1/-2 to the slug, so #slug silently points -// to the first; a link to the second is wrong by default +// heading-duplicate two sibling headings (same parent) with identical text +// — GitHub appends -1/-2 to the slug, so #slug silently +// points to the first; CHANGELOG ### Added under different +// ## versions is NOT flagged (different parents, MD024 +// siblings_only semantics) // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -129,7 +131,14 @@ export function checkDocument(src, opts = {}) { // ---- headings ------------------------------------------------------------- let prevDepth = 0; let h1Seen = false; - const headingSlugs = new Map(); // slug -> first node (for heading-duplicate) + // heading-duplicate (siblings-only, MD024 semantics): flag a duplicate only + // when both headings share the same parent section. A CHANGELOG with + // ### Added under ## 1.0.0 and ### Added under ## 2.0.0 is the + // world-standard keepachangelog format — different parents, not a duplicate. + // Same parent + same text = the ambiguous case where #slug silently hits + // the first occurrence. + const parentAt = []; // parentAt[depth] = slug of the current heading at that depth + const siblingsSeen = new Map(); // "parentKey/slug" -> first node walk(root, (node) => { if (node.type !== 'heading') return; if (prevDepth && node.depth > prevDepth + 1) { @@ -140,18 +149,21 @@ export function checkDocument(src, opts = {}) { if (h1Seen) add('heading-multiple-h1', node, 'more than one top-level (h1) heading in this document'); h1Seen = true; } - // heading-duplicate: same rendered text at any depth — GitHub appends -1, - // -2, ... to the slug, so a link to #slug silently hits the FIRST one even - // when the author meant the second. Deterministic: identical text at any - // level is objectively ambiguous. const text = textContent(node); const slug = safeDecode(text).trim().toLowerCase(); if (slug) { - const prev = headingSlugs.get(slug); + // update parent stack: this heading becomes the parent for deeper levels, + // and clears any stale parents below it + parentAt[node.depth] = slug; + for (let d = node.depth + 1; d < parentAt.length; d++) parentAt[d] = undefined; + // the sibling key: parent's slug (depth-1) + this heading's slug + const parentKey = node.depth > 1 ? (parentAt[node.depth - 1] || '') : ''; + const sibKey = parentKey + '/' + slug; + const prev = siblingsSeen.get(sibKey); if (prev) { - add('heading-duplicate', node, `duplicate heading "${text}" (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); + add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); } else { - headingSlugs.set(slug, node); + siblingsSeen.set(sibKey, node); } } }); diff --git a/plugin/skills/doc-structure/lib/md-checks.mjs b/plugin/skills/doc-structure/lib/md-checks.mjs index e730947..eec1ed4 100644 --- a/plugin/skills/doc-structure/lib/md-checks.mjs +++ b/plugin/skills/doc-structure/lib/md-checks.mjs @@ -9,9 +9,11 @@ // Checks (ids are stable API): // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc -// heading-duplicate two headings with identical rendered text — GitHub -// appends -1/-2 to the slug, so #slug silently points -// to the first; a link to the second is wrong by default +// heading-duplicate two sibling headings (same parent) with identical text +// — GitHub appends -1/-2 to the slug, so #slug silently +// points to the first; CHANGELOG ### Added under different +// ## versions is NOT flagged (different parents, MD024 +// siblings_only semantics) // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -129,7 +131,14 @@ export function checkDocument(src, opts = {}) { // ---- headings ------------------------------------------------------------- let prevDepth = 0; let h1Seen = false; - const headingSlugs = new Map(); // slug -> first node (for heading-duplicate) + // heading-duplicate (siblings-only, MD024 semantics): flag a duplicate only + // when both headings share the same parent section. A CHANGELOG with + // ### Added under ## 1.0.0 and ### Added under ## 2.0.0 is the + // world-standard keepachangelog format — different parents, not a duplicate. + // Same parent + same text = the ambiguous case where #slug silently hits + // the first occurrence. + const parentAt = []; // parentAt[depth] = slug of the current heading at that depth + const siblingsSeen = new Map(); // "parentKey/slug" -> first node walk(root, (node) => { if (node.type !== 'heading') return; if (prevDepth && node.depth > prevDepth + 1) { @@ -140,18 +149,21 @@ export function checkDocument(src, opts = {}) { if (h1Seen) add('heading-multiple-h1', node, 'more than one top-level (h1) heading in this document'); h1Seen = true; } - // heading-duplicate: same rendered text at any depth — GitHub appends -1, - // -2, ... to the slug, so a link to #slug silently hits the FIRST one even - // when the author meant the second. Deterministic: identical text at any - // level is objectively ambiguous. const text = textContent(node); const slug = safeDecode(text).trim().toLowerCase(); if (slug) { - const prev = headingSlugs.get(slug); + // update parent stack: this heading becomes the parent for deeper levels, + // and clears any stale parents below it + parentAt[node.depth] = slug; + for (let d = node.depth + 1; d < parentAt.length; d++) parentAt[d] = undefined; + // the sibling key: parent's slug (depth-1) + this heading's slug + const parentKey = node.depth > 1 ? (parentAt[node.depth - 1] || '') : ''; + const sibKey = parentKey + '/' + slug; + const prev = siblingsSeen.get(sibKey); if (prev) { - add('heading-duplicate', node, `duplicate heading "${text}" (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); + add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); } else { - headingSlugs.set(slug, node); + siblingsSeen.set(sibKey, node); } } }); diff --git a/scripts/fixtures/decoy-clean.md b/scripts/fixtures/decoy-clean.md index 81bb966..4a491ed 100644 --- a/scripts/fixtures/decoy-clean.md +++ b/scripts/fixtures/decoy-clean.md @@ -53,3 +53,23 @@ External targets are skipped: [https scheme](https://example.com/x), [protocol-r [ok-def]: ./decoy-thai.md "A live target" [collapsed]: #repeated-heading [shortcut]: https://example.com/defined + +## 1.0.0 + +### Added + +- first feature + +### Fixed + +- first fix + +## 2.0.0 + +### Added + +- second feature + +### Fixed + +- second fix diff --git a/scripts/lib/md-checks.mjs b/scripts/lib/md-checks.mjs index e730947..eec1ed4 100644 --- a/scripts/lib/md-checks.mjs +++ b/scripts/lib/md-checks.mjs @@ -9,9 +9,11 @@ // Checks (ids are stable API): // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc -// heading-duplicate two headings with identical rendered text — GitHub -// appends -1/-2 to the slug, so #slug silently points -// to the first; a link to the second is wrong by default +// heading-duplicate two sibling headings (same parent) with identical text +// — GitHub appends -1/-2 to the slug, so #slug silently +// points to the first; CHANGELOG ### Added under different +// ## versions is NOT flagged (different parents, MD024 +// siblings_only semantics) // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -129,7 +131,14 @@ export function checkDocument(src, opts = {}) { // ---- headings ------------------------------------------------------------- let prevDepth = 0; let h1Seen = false; - const headingSlugs = new Map(); // slug -> first node (for heading-duplicate) + // heading-duplicate (siblings-only, MD024 semantics): flag a duplicate only + // when both headings share the same parent section. A CHANGELOG with + // ### Added under ## 1.0.0 and ### Added under ## 2.0.0 is the + // world-standard keepachangelog format — different parents, not a duplicate. + // Same parent + same text = the ambiguous case where #slug silently hits + // the first occurrence. + const parentAt = []; // parentAt[depth] = slug of the current heading at that depth + const siblingsSeen = new Map(); // "parentKey/slug" -> first node walk(root, (node) => { if (node.type !== 'heading') return; if (prevDepth && node.depth > prevDepth + 1) { @@ -140,18 +149,21 @@ export function checkDocument(src, opts = {}) { if (h1Seen) add('heading-multiple-h1', node, 'more than one top-level (h1) heading in this document'); h1Seen = true; } - // heading-duplicate: same rendered text at any depth — GitHub appends -1, - // -2, ... to the slug, so a link to #slug silently hits the FIRST one even - // when the author meant the second. Deterministic: identical text at any - // level is objectively ambiguous. const text = textContent(node); const slug = safeDecode(text).trim().toLowerCase(); if (slug) { - const prev = headingSlugs.get(slug); + // update parent stack: this heading becomes the parent for deeper levels, + // and clears any stale parents below it + parentAt[node.depth] = slug; + for (let d = node.depth + 1; d < parentAt.length; d++) parentAt[d] = undefined; + // the sibling key: parent's slug (depth-1) + this heading's slug + const parentKey = node.depth > 1 ? (parentAt[node.depth - 1] || '') : ''; + const sibKey = parentKey + '/' + slug; + const prev = siblingsSeen.get(sibKey); if (prev) { - add('heading-duplicate', node, `duplicate heading "${text}" (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); + add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); } else { - headingSlugs.set(slug, node); + siblingsSeen.set(sibKey, node); } } }); From 3be71b1c1ca1488729381b72c41b5043c0d9e79d Mon Sep 17 00:00:00 2001 From: mehvetero <270047+mehvetero@users.noreply.github.com> Date: Tue, 28 Jul 2026 15:48:12 +0530 Subject: [PATCH 4/5] refactor: format-neutral doc comment + message wording MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugin/scripts/lib/md-checks.mjs | 16 +++++++++------- plugin/skills/doc-structure/lib/md-checks.mjs | 16 +++++++++------- scripts/lib/md-checks.mjs | 16 +++++++++------- 3 files changed, 27 insertions(+), 21 deletions(-) diff --git a/plugin/scripts/lib/md-checks.mjs b/plugin/scripts/lib/md-checks.mjs index eec1ed4..cf78f3d 100644 --- a/plugin/scripts/lib/md-checks.mjs +++ b/plugin/scripts/lib/md-checks.mjs @@ -10,10 +10,11 @@ // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc // heading-duplicate two sibling headings (same parent) with identical text -// — GitHub appends -1/-2 to the slug, so #slug silently -// points to the first; CHANGELOG ### Added under different -// ## versions is NOT flagged (different parents, MD024 -// siblings_only semantics) +// — duplicate headings make auto-generated anchors +// ambiguous (markdown -1 suffix, HTML id uniqueness, +// screen-reader jump-to-heading); CHANGELOG ### Added +// under different ## versions is NOT flagged (different +// parents, MD024 siblings_only semantics) // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -135,8 +136,9 @@ export function checkDocument(src, opts = {}) { // when both headings share the same parent section. A CHANGELOG with // ### Added under ## 1.0.0 and ### Added under ## 2.0.0 is the // world-standard keepachangelog format — different parents, not a duplicate. - // Same parent + same text = the ambiguous case where #slug silently hits - // the first occurrence. + // Same parent + same text = ambiguous auto-generated anchors (the defect is + // format-general: markdown -1 suffix, HTML id uniqueness, screen-reader + // jump-to-heading all break on duplicates independently). const parentAt = []; // parentAt[depth] = slug of the current heading at that depth const siblingsSeen = new Map(); // "parentKey/slug" -> first node walk(root, (node) => { @@ -161,7 +163,7 @@ export function checkDocument(src, opts = {}) { const sibKey = parentKey + '/' + slug; const prev = siblingsSeen.get(sibKey); if (prev) { - add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); + add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — auto-generated anchors become ambiguous`); } else { siblingsSeen.set(sibKey, node); } diff --git a/plugin/skills/doc-structure/lib/md-checks.mjs b/plugin/skills/doc-structure/lib/md-checks.mjs index eec1ed4..cf78f3d 100644 --- a/plugin/skills/doc-structure/lib/md-checks.mjs +++ b/plugin/skills/doc-structure/lib/md-checks.mjs @@ -10,10 +10,11 @@ // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc // heading-duplicate two sibling headings (same parent) with identical text -// — GitHub appends -1/-2 to the slug, so #slug silently -// points to the first; CHANGELOG ### Added under different -// ## versions is NOT flagged (different parents, MD024 -// siblings_only semantics) +// — duplicate headings make auto-generated anchors +// ambiguous (markdown -1 suffix, HTML id uniqueness, +// screen-reader jump-to-heading); CHANGELOG ### Added +// under different ## versions is NOT flagged (different +// parents, MD024 siblings_only semantics) // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -135,8 +136,9 @@ export function checkDocument(src, opts = {}) { // when both headings share the same parent section. A CHANGELOG with // ### Added under ## 1.0.0 and ### Added under ## 2.0.0 is the // world-standard keepachangelog format — different parents, not a duplicate. - // Same parent + same text = the ambiguous case where #slug silently hits - // the first occurrence. + // Same parent + same text = ambiguous auto-generated anchors (the defect is + // format-general: markdown -1 suffix, HTML id uniqueness, screen-reader + // jump-to-heading all break on duplicates independently). const parentAt = []; // parentAt[depth] = slug of the current heading at that depth const siblingsSeen = new Map(); // "parentKey/slug" -> first node walk(root, (node) => { @@ -161,7 +163,7 @@ export function checkDocument(src, opts = {}) { const sibKey = parentKey + '/' + slug; const prev = siblingsSeen.get(sibKey); if (prev) { - add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); + add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — auto-generated anchors become ambiguous`); } else { siblingsSeen.set(sibKey, node); } diff --git a/scripts/lib/md-checks.mjs b/scripts/lib/md-checks.mjs index eec1ed4..cf78f3d 100644 --- a/scripts/lib/md-checks.mjs +++ b/scripts/lib/md-checks.mjs @@ -10,10 +10,11 @@ // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc // heading-duplicate two sibling headings (same parent) with identical text -// — GitHub appends -1/-2 to the slug, so #slug silently -// points to the first; CHANGELOG ### Added under different -// ## versions is NOT flagged (different parents, MD024 -// siblings_only semantics) +// — duplicate headings make auto-generated anchors +// ambiguous (markdown -1 suffix, HTML id uniqueness, +// screen-reader jump-to-heading); CHANGELOG ### Added +// under different ## versions is NOT flagged (different +// parents, MD024 siblings_only semantics) // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -135,8 +136,9 @@ export function checkDocument(src, opts = {}) { // when both headings share the same parent section. A CHANGELOG with // ### Added under ## 1.0.0 and ### Added under ## 2.0.0 is the // world-standard keepachangelog format — different parents, not a duplicate. - // Same parent + same text = the ambiguous case where #slug silently hits - // the first occurrence. + // Same parent + same text = ambiguous auto-generated anchors (the defect is + // format-general: markdown -1 suffix, HTML id uniqueness, screen-reader + // jump-to-heading all break on duplicates independently). const parentAt = []; // parentAt[depth] = slug of the current heading at that depth const siblingsSeen = new Map(); // "parentKey/slug" -> first node walk(root, (node) => { @@ -161,7 +163,7 @@ export function checkDocument(src, opts = {}) { const sibKey = parentKey + '/' + slug; const prev = siblingsSeen.get(sibKey); if (prev) { - add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — anchor #${slug} silently points to the first occurrence`); + add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — auto-generated anchors become ambiguous`); } else { siblingsSeen.set(sibKey, node); } From 5280bc1bbb6a1817ae273dd454cc3d02ff57224b Mon Sep 17 00:00:00 2001 From: mehvetero <270047+mehvetero@users.noreply.github.com> Date: Tue, 28 Jul 2026 17:05:28 +0530 Subject: [PATCH 5/5] =?UTF-8?q?fix:=20F1-F5=20from=20review=20=E2=80=94=20?= =?UTF-8?q?id-based=20parent=20tracking,=20doc=20contract,=20wording?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- plugin/scripts/lib/md-checks.mjs | 54 +++++++++++-------- plugin/skills/doc-structure/SKILL.md | 1 + plugin/skills/doc-structure/lib/md-checks.mjs | 54 +++++++++++-------- scripts/lib/md-checks.mjs | 54 +++++++++++-------- skills/doc-structure/SKILL.md | 1 + 5 files changed, 98 insertions(+), 66 deletions(-) diff --git a/plugin/scripts/lib/md-checks.mjs b/plugin/scripts/lib/md-checks.mjs index cf78f3d..59a795d 100644 --- a/plugin/scripts/lib/md-checks.mjs +++ b/plugin/scripts/lib/md-checks.mjs @@ -9,12 +9,13 @@ // Checks (ids are stable API): // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc -// heading-duplicate two sibling headings (same parent) with identical text -// — duplicate headings make auto-generated anchors -// ambiguous (markdown -1 suffix, HTML id uniqueness, -// screen-reader jump-to-heading); CHANGELOG ### Added -// under different ## versions is NOT flagged (different -// parents, MD024 siblings_only semantics) +// heading-duplicate two sibling headings (same parent node) with identical +// rendered text — a plain #slug link reaches only the +// first; suffixed anchors are order-fragile. Format- +// general (markdown, HTML id, screen-reader). Siblings- +// only: CHANGELOG ### Added under different ## versions +// is NOT flagged (different parent nodes). Keyed on text, +// not slug — "Setup!" and "Setup" are not flagged (MD024) // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -135,12 +136,18 @@ export function checkDocument(src, opts = {}) { // heading-duplicate (siblings-only, MD024 semantics): flag a duplicate only // when both headings share the same parent section. A CHANGELOG with // ### Added under ## 1.0.0 and ### Added under ## 2.0.0 is the - // world-standard keepachangelog format — different parents, not a duplicate. - // Same parent + same text = ambiguous auto-generated anchors (the defect is - // format-general: markdown -1 suffix, HTML id uniqueness, screen-reader - // jump-to-heading all break on duplicates independently). - const parentAt = []; // parentAt[depth] = slug of the current heading at that depth - const siblingsSeen = new Map(); // "parentKey/slug" -> first node + // keepachangelog format — different parent nodes, not siblings. + // Same parent node + same text = a plain #slug link reaches only the first + // occurrence; the suffixed anchors (#slug-1) are order-fragile and readers + // cannot predict them. The defect is format-general: duplicate headings make + // auto-generated identifiers ambiguous in markdown, HTML, AsciiDoc, and + // screen-reader jump-to-heading navigation alike. + // Known limit: headings with different text that slug to the same anchor + // (e.g. "Setup!" and "Setup" both slug to "setup") are NOT flagged — the + // key is the rendered text, matching markdownlint MD024 behavior. + let headingSeq = 0; + const ancestorId = []; // ancestorId[depth] = id of the current heading at that depth + const siblingsSeen = new Map(); // "parentId/text" -> first node walk(root, (node) => { if (node.type !== 'heading') return; if (prevDepth && node.depth > prevDepth + 1) { @@ -152,18 +159,21 @@ export function checkDocument(src, opts = {}) { h1Seen = true; } const text = textContent(node); - const slug = safeDecode(text).trim().toLowerCase(); - if (slug) { - // update parent stack: this heading becomes the parent for deeper levels, - // and clears any stale parents below it - parentAt[node.depth] = slug; - for (let d = node.depth + 1; d < parentAt.length; d++) parentAt[d] = undefined; - // the sibling key: parent's slug (depth-1) + this heading's slug - const parentKey = node.depth > 1 ? (parentAt[node.depth - 1] || '') : ''; - const sibKey = parentKey + '/' + slug; + const key = text.trim().toLowerCase(); + if (key) { + const thisId = ++headingSeq; + // find the nearest defined ancestor: walk depth-1 down to 1 + let parentId = 0; // 0 = document root + for (let d = node.depth - 1; d >= 1; d--) { + if (ancestorId[d] !== undefined) { parentId = ancestorId[d]; break; } + } + // register this heading as the ancestor for deeper levels, clear stale + ancestorId[node.depth] = thisId; + for (let d = node.depth + 1; d < ancestorId.length; d++) ancestorId[d] = undefined; + const sibKey = parentId + '/' + key; const prev = siblingsSeen.get(sibKey); if (prev) { - add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — auto-generated anchors become ambiguous`); + add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — a plain #${text.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')} link reaches only the first occurrence`); } else { siblingsSeen.set(sibKey, node); } diff --git a/plugin/skills/doc-structure/SKILL.md b/plugin/skills/doc-structure/SKILL.md index 1ebb358..2c5fadc 100644 --- a/plugin/skills/doc-structure/SKILL.md +++ b/plugin/skills/doc-structure/SKILL.md @@ -30,6 +30,7 @@ Scan markdown docs for structural breakage. Report CONFIRMED findings. Fix on re |---|---| | heading-skip | level jumps (h1 -> h3) | | heading-multiple-h1 | more than one top-level title | +| heading-duplicate | same-parent sibling headings with identical text — a plain #slug link reaches only the first; keep-a-changelog per-release `### Added` repeats under different `## version` parents are deliberately not flagged | | anchor-missing | #fragment resolves to no heading slug / HTML id (same-file + cross-file, case-mismatch hinted) | | file-missing | dead relative link/image/definition target | | table-ragged | row with MORE cells than the header (GitHub silently drops them) | diff --git a/plugin/skills/doc-structure/lib/md-checks.mjs b/plugin/skills/doc-structure/lib/md-checks.mjs index cf78f3d..59a795d 100644 --- a/plugin/skills/doc-structure/lib/md-checks.mjs +++ b/plugin/skills/doc-structure/lib/md-checks.mjs @@ -9,12 +9,13 @@ // Checks (ids are stable API): // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc -// heading-duplicate two sibling headings (same parent) with identical text -// — duplicate headings make auto-generated anchors -// ambiguous (markdown -1 suffix, HTML id uniqueness, -// screen-reader jump-to-heading); CHANGELOG ### Added -// under different ## versions is NOT flagged (different -// parents, MD024 siblings_only semantics) +// heading-duplicate two sibling headings (same parent node) with identical +// rendered text — a plain #slug link reaches only the +// first; suffixed anchors are order-fragile. Format- +// general (markdown, HTML id, screen-reader). Siblings- +// only: CHANGELOG ### Added under different ## versions +// is NOT flagged (different parent nodes). Keyed on text, +// not slug — "Setup!" and "Setup" are not flagged (MD024) // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -135,12 +136,18 @@ export function checkDocument(src, opts = {}) { // heading-duplicate (siblings-only, MD024 semantics): flag a duplicate only // when both headings share the same parent section. A CHANGELOG with // ### Added under ## 1.0.0 and ### Added under ## 2.0.0 is the - // world-standard keepachangelog format — different parents, not a duplicate. - // Same parent + same text = ambiguous auto-generated anchors (the defect is - // format-general: markdown -1 suffix, HTML id uniqueness, screen-reader - // jump-to-heading all break on duplicates independently). - const parentAt = []; // parentAt[depth] = slug of the current heading at that depth - const siblingsSeen = new Map(); // "parentKey/slug" -> first node + // keepachangelog format — different parent nodes, not siblings. + // Same parent node + same text = a plain #slug link reaches only the first + // occurrence; the suffixed anchors (#slug-1) are order-fragile and readers + // cannot predict them. The defect is format-general: duplicate headings make + // auto-generated identifiers ambiguous in markdown, HTML, AsciiDoc, and + // screen-reader jump-to-heading navigation alike. + // Known limit: headings with different text that slug to the same anchor + // (e.g. "Setup!" and "Setup" both slug to "setup") are NOT flagged — the + // key is the rendered text, matching markdownlint MD024 behavior. + let headingSeq = 0; + const ancestorId = []; // ancestorId[depth] = id of the current heading at that depth + const siblingsSeen = new Map(); // "parentId/text" -> first node walk(root, (node) => { if (node.type !== 'heading') return; if (prevDepth && node.depth > prevDepth + 1) { @@ -152,18 +159,21 @@ export function checkDocument(src, opts = {}) { h1Seen = true; } const text = textContent(node); - const slug = safeDecode(text).trim().toLowerCase(); - if (slug) { - // update parent stack: this heading becomes the parent for deeper levels, - // and clears any stale parents below it - parentAt[node.depth] = slug; - for (let d = node.depth + 1; d < parentAt.length; d++) parentAt[d] = undefined; - // the sibling key: parent's slug (depth-1) + this heading's slug - const parentKey = node.depth > 1 ? (parentAt[node.depth - 1] || '') : ''; - const sibKey = parentKey + '/' + slug; + const key = text.trim().toLowerCase(); + if (key) { + const thisId = ++headingSeq; + // find the nearest defined ancestor: walk depth-1 down to 1 + let parentId = 0; // 0 = document root + for (let d = node.depth - 1; d >= 1; d--) { + if (ancestorId[d] !== undefined) { parentId = ancestorId[d]; break; } + } + // register this heading as the ancestor for deeper levels, clear stale + ancestorId[node.depth] = thisId; + for (let d = node.depth + 1; d < ancestorId.length; d++) ancestorId[d] = undefined; + const sibKey = parentId + '/' + key; const prev = siblingsSeen.get(sibKey); if (prev) { - add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — auto-generated anchors become ambiguous`); + add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — a plain #${text.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')} link reaches only the first occurrence`); } else { siblingsSeen.set(sibKey, node); } diff --git a/scripts/lib/md-checks.mjs b/scripts/lib/md-checks.mjs index cf78f3d..59a795d 100644 --- a/scripts/lib/md-checks.mjs +++ b/scripts/lib/md-checks.mjs @@ -9,12 +9,13 @@ // Checks (ids are stable API): // heading-skip heading level jumps down more than one (h1 -> h3) // heading-multiple-h1 more than one top-level heading in a doc -// heading-duplicate two sibling headings (same parent) with identical text -// — duplicate headings make auto-generated anchors -// ambiguous (markdown -1 suffix, HTML id uniqueness, -// screen-reader jump-to-heading); CHANGELOG ### Added -// under different ## versions is NOT flagged (different -// parents, MD024 siblings_only semantics) +// heading-duplicate two sibling headings (same parent node) with identical +// rendered text — a plain #slug link reaches only the +// first; suffixed anchors are order-fragile. Format- +// general (markdown, HTML id, screen-reader). Siblings- +// only: CHANGELOG ### Added under different ## versions +// is NOT flagged (different parent nodes). Keyed on text, +// not slug — "Setup!" and "Setup" are not flagged (MD024) // anchor-missing #fragment (same-file or file.md#frag) resolves to no // heading slug / HTML id — incl. a case-mismatch hint // file-missing relative link/image/definition target absent on disk @@ -135,12 +136,18 @@ export function checkDocument(src, opts = {}) { // heading-duplicate (siblings-only, MD024 semantics): flag a duplicate only // when both headings share the same parent section. A CHANGELOG with // ### Added under ## 1.0.0 and ### Added under ## 2.0.0 is the - // world-standard keepachangelog format — different parents, not a duplicate. - // Same parent + same text = ambiguous auto-generated anchors (the defect is - // format-general: markdown -1 suffix, HTML id uniqueness, screen-reader - // jump-to-heading all break on duplicates independently). - const parentAt = []; // parentAt[depth] = slug of the current heading at that depth - const siblingsSeen = new Map(); // "parentKey/slug" -> first node + // keepachangelog format — different parent nodes, not siblings. + // Same parent node + same text = a plain #slug link reaches only the first + // occurrence; the suffixed anchors (#slug-1) are order-fragile and readers + // cannot predict them. The defect is format-general: duplicate headings make + // auto-generated identifiers ambiguous in markdown, HTML, AsciiDoc, and + // screen-reader jump-to-heading navigation alike. + // Known limit: headings with different text that slug to the same anchor + // (e.g. "Setup!" and "Setup" both slug to "setup") are NOT flagged — the + // key is the rendered text, matching markdownlint MD024 behavior. + let headingSeq = 0; + const ancestorId = []; // ancestorId[depth] = id of the current heading at that depth + const siblingsSeen = new Map(); // "parentId/text" -> first node walk(root, (node) => { if (node.type !== 'heading') return; if (prevDepth && node.depth > prevDepth + 1) { @@ -152,18 +159,21 @@ export function checkDocument(src, opts = {}) { h1Seen = true; } const text = textContent(node); - const slug = safeDecode(text).trim().toLowerCase(); - if (slug) { - // update parent stack: this heading becomes the parent for deeper levels, - // and clears any stale parents below it - parentAt[node.depth] = slug; - for (let d = node.depth + 1; d < parentAt.length; d++) parentAt[d] = undefined; - // the sibling key: parent's slug (depth-1) + this heading's slug - const parentKey = node.depth > 1 ? (parentAt[node.depth - 1] || '') : ''; - const sibKey = parentKey + '/' + slug; + const key = text.trim().toLowerCase(); + if (key) { + const thisId = ++headingSeq; + // find the nearest defined ancestor: walk depth-1 down to 1 + let parentId = 0; // 0 = document root + for (let d = node.depth - 1; d >= 1; d--) { + if (ancestorId[d] !== undefined) { parentId = ancestorId[d]; break; } + } + // register this heading as the ancestor for deeper levels, clear stale + ancestorId[node.depth] = thisId; + for (let d = node.depth + 1; d < ancestorId.length; d++) ancestorId[d] = undefined; + const sibKey = parentId + '/' + key; const prev = siblingsSeen.get(sibKey); if (prev) { - add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — auto-generated anchors become ambiguous`); + add('heading-duplicate', node, `duplicate heading "${text}" under the same parent (first at line ${at(prev).line}) — a plain #${text.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')} link reaches only the first occurrence`); } else { siblingsSeen.set(sibKey, node); } diff --git a/skills/doc-structure/SKILL.md b/skills/doc-structure/SKILL.md index 1ebb358..2c5fadc 100644 --- a/skills/doc-structure/SKILL.md +++ b/skills/doc-structure/SKILL.md @@ -30,6 +30,7 @@ Scan markdown docs for structural breakage. Report CONFIRMED findings. Fix on re |---|---| | heading-skip | level jumps (h1 -> h3) | | heading-multiple-h1 | more than one top-level title | +| heading-duplicate | same-parent sibling headings with identical text — a plain #slug link reaches only the first; keep-a-changelog per-release `### Added` repeats under different `## version` parents are deliberately not flagged | | anchor-missing | #fragment resolves to no heading slug / HTML id (same-file + cross-file, case-mismatch hinted) | | file-missing | dead relative link/image/definition target | | table-ragged | row with MORE cells than the header (GitHub silently drops them) |