diff --git a/plugin/scripts/lib/md-checks.mjs b/plugin/scripts/lib/md-checks.mjs index 84f4760..59a795d 100644 --- a/plugin/scripts/lib/md-checks.mjs +++ b/plugin/scripts/lib/md-checks.mjs @@ -9,6 +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 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 @@ -126,6 +133,21 @@ export function checkDocument(src, opts = {}) { // ---- headings ------------------------------------------------------------- let prevDepth = 0; let h1Seen = false; + // 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 + // 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) { @@ -136,6 +158,26 @@ export function checkDocument(src, opts = {}) { if (h1Seen) add('heading-multiple-h1', node, 'more than one top-level (h1) heading in this document'); h1Seen = true; } + const text = textContent(node); + 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}) — a plain #${text.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')} link reaches only the first occurrence`); + } else { + siblingsSeen.set(sibKey, node); + } + } }); // ---- link / image / definition targets ------------------------------------ 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 84f4760..59a795d 100644 --- a/plugin/skills/doc-structure/lib/md-checks.mjs +++ b/plugin/skills/doc-structure/lib/md-checks.mjs @@ -9,6 +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 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 @@ -126,6 +133,21 @@ export function checkDocument(src, opts = {}) { // ---- headings ------------------------------------------------------------- let prevDepth = 0; let h1Seen = false; + // 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 + // 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) { @@ -136,6 +158,26 @@ export function checkDocument(src, opts = {}) { if (h1Seen) add('heading-multiple-h1', node, 'more than one top-level (h1) heading in this document'); h1Seen = true; } + const text = textContent(node); + 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}) — a plain #${text.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')} link reaches only the first occurrence`); + } else { + siblingsSeen.set(sibKey, node); + } + } }); // ---- link / image / definition targets ------------------------------------ diff --git a/scripts/fixtures/decoy-clean.md b/scripts/fixtures/decoy-clean.md index bfb7e44..4a491ed 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). @@ -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/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..59a795d 100644 --- a/scripts/lib/md-checks.mjs +++ b/scripts/lib/md-checks.mjs @@ -9,6 +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 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 @@ -126,6 +133,21 @@ export function checkDocument(src, opts = {}) { // ---- headings ------------------------------------------------------------- let prevDepth = 0; let h1Seen = false; + // 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 + // 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) { @@ -136,6 +158,26 @@ export function checkDocument(src, opts = {}) { if (h1Seen) add('heading-multiple-h1', node, 'more than one top-level (h1) heading in this document'); h1Seen = true; } + const text = textContent(node); + 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}) — a plain #${text.trim().toLowerCase().replace(/\s+/g, '-').replace(/[^\w-]/g, '')} link reaches only the first occurrence`); + } else { + siblingsSeen.set(sibKey, 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')); }); // --------------------------------------------------------------------------- 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) |