Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 42 additions & 0 deletions plugin/scripts/lib/md-checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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 ------------------------------------
Expand Down
1 change: 1 addition & 0 deletions plugin/skills/doc-structure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down
42 changes: 42 additions & 0 deletions plugin/skills/doc-structure/lib/md-checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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 ------------------------------------
Expand Down
24 changes: 22 additions & 2 deletions scripts/fixtures/decoy-clean.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@

https://in-indented-code.example.com/also-fine

```text

Check failure on line 14 in scripts/fixtures/decoy-clean.md

View workflow job for this annotation

GitHub Actions / lint

Code block style

scripts/fixtures/decoy-clean.md:14 MD046/code-block-style Code block style [Expected: indented; Actual: fenced] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md046.md
https://in-fenced-code.example.com/fine-too
[not-a-ref][nope] inside a fence
| bad | table | inside | fence | with | extras |
Expand All @@ -19,9 +19,9 @@

## 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: <a id="custom-anchor"></a> then [jump](#custom-anchor).

Expand All @@ -29,7 +29,7 @@

Link to it: [fancy](#heading-with-code-and-emphasis).

Setext heading

Check failure on line 32 in scripts/fixtures/decoy-clean.md

View workflow job for this annotation

GitHub Actions / lint

Heading style

scripts/fixtures/decoy-clean.md:32 MD003/heading-style Heading style [Expected: atx; Actual: setext] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md003.md
--------------

Link: [setext](#setext-heading).
Expand All @@ -37,7 +37,7 @@
| Column A | Column B |
| :--- | ---: |
| escaped \| pipe | ok |
| padded row |

Check failure on line 40 in scripts/fixtures/decoy-clean.md

View workflow job for this annotation

GitHub Actions / lint

Table column count

scripts/fixtures/decoy-clean.md:40:14 MD056/table-column-count Table column count [Expected: 2; Actual: 1; Too few cells, row will be missing data] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md056.md

> Blockquote with a [good ref][ok-def] inside.
> Lazy continuation line stays in the quote.
Expand All @@ -53,3 +53,23 @@
[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
6 changes: 6 additions & 0 deletions scripts/fixtures/defects-structure.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# Defects fixture

### Skipped level (h1 to h3)

Check failure on line 3 in scripts/fixtures/defects-structure.md

View workflow job for this annotation

GitHub Actions / lint

Heading levels should only increment by one level at a time

scripts/fixtures/defects-structure.md:3 MD001/heading-increment Heading levels should only increment by one level at a time [Expected: h2; Actual: h3] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md001.md

# Second top-level title

Check failure on line 5 in scripts/fixtures/defects-structure.md

View workflow job for this annotation

GitHub Actions / lint

Multiple top-level headings in the same document

scripts/fixtures/defects-structure.md:5 MD025/single-title/single-h1 Multiple top-level headings in the same document [Context: "Second top-level title"] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md025.md

Link to [a missing anchor](#no-such-heading) here.

Check failure on line 7 in scripts/fixtures/defects-structure.md

View workflow job for this annotation

GitHub Actions / lint

Link fragments should be valid

scripts/fixtures/defects-structure.md:7:9 MD051/link-fragments Link fragments should be valid [Context: "[a missing anchor](#no-such-heading)"] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md051.md

Link to [a case mismatch](#Skipped-level-h1-to-h3) here.

Check failure on line 9 in scripts/fixtures/defects-structure.md

View workflow job for this annotation

GitHub Actions / lint

Link fragments should be valid

scripts/fixtures/defects-structure.md:9:9 MD051/link-fragments Link fragments should be valid [Expected: #skipped-level-h1-to-h3; Actual: #Skipped-level-h1-to-h3] [Context: "[a case mismatch](#Skipped-level-h1-to-h3)"] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md051.md

Link to [a dead file](./no-such-file.md) here.

Expand All @@ -18,10 +18,16 @@

| a | b |
| --- | --- |
| 1 | 2 | 3 |

Check failure on line 21 in scripts/fixtures/defects-structure.md

View workflow job for this annotation

GitHub Actions / lint

Table column count

scripts/fixtures/defects-structure.md:21:9 MD056/table-column-count Table column count [Expected: 2; Actual: 3; Too many cells, extra data will be missing] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md056.md

See [broken ref][no-def] for details.

Check failure on line 23 in scripts/fixtures/defects-structure.md

View workflow job for this annotation

GitHub Actions / lint

Reference links and images should use a label that is defined

scripts/fixtures/defects-structure.md:23:5 MD052/reference-links-images Reference links and images should use a label that is defined [Missing link or image reference definition: "no-def"] [Context: "[broken ref][no-def]"] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md052.md

Bare URL: https://example.com/dangling in prose.

Check failure on line 25 in scripts/fixtures/defects-structure.md

View workflow job for this annotation

GitHub Actions / lint

Bare URL used

scripts/fixtures/defects-structure.md:25:11 MD034/no-bare-urls Bare URL used [Context: "https://example.com/dangling"] https://github.com/DavidAnson/markdownlint/blob/v0.41.1/doc/md034.md

[orphan-def]: https://example.com/orphan

## Setup

Some instructions.

## Setup
42 changes: 42 additions & 0 deletions scripts/lib/md-checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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) {
Expand All @@ -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 ------------------------------------
Expand Down
7 changes: 5 additions & 2 deletions scripts/lib/md-checks.test.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down Expand Up @@ -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'));
});

// ---------------------------------------------------------------------------
Expand Down
1 change: 1 addition & 0 deletions skills/doc-structure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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) |
Expand Down