Skip to content
Closed
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
57 changes: 57 additions & 0 deletions plugin/scripts/lib/md-checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
// ref-undefined full/collapsed reference [text][label] with no
// definition (renders as literal brackets on GitHub)
// def-orphan a [label]: definition no reference ever uses
// image-alt-missing image with empty alt — opt-in (default off, enable via
// opts.enableImageAlt); WCAG 1.1.1 treats empty alt as
// correct for decorative images, so the finding names the
// exception (MD045 class)
// image-alt-too-short alt below the script-aware grapheme floor (CJK: 1,
// default: 3) — "img" is junk, "図" is a word
// image-alt-too-long alt exceeds 125 graphemes — alt is a label, not a
// caption (WAI concision convention)
// bare-url a raw http(s)/www URL in prose text (GFM auto-links
// it, CommonMark does not; MD034 class — style signal)
// doc-too-large pre-parse short-circuit: input over MAX_DOC_BYTES is
Expand Down Expand Up @@ -246,6 +254,55 @@ export function checkDocument(src, opts = {}) {
if (node.type === 'link' || node.type === 'image' || node.type === 'definition') checkTarget(node, node.url);
});

// ---- images: alt text (opt-in, default off) --------------------------------
// MD045 / WCAG 1.1.1. Gated by opts.enableImageAlt (default false) — in
// agent-authored or internal repos the value concentrates in public-facing
// content images, not every decorative icon; the caller (conductor / config)
// decides when to flip it on. Note: WCAG 1.1.1 itself treats empty alt as
// CORRECT for purely decorative images — the finding says so, leaving the
// judgment to the reviewing agent.
//
// Length bounds (grapheme clusters via Intl.Segmenter — zero-dependency,
// Node 16+). The floor is script-aware: a single CJK character can be a
// complete description ("図"), while "img" (3 Latin chars) is junk. The
// ceiling is language-fair with a single number (it bounds excess, not
// meaning): 125 graphemes, matching the WAI screen-reader concision
// convention (alt is a label, not a caption).
if (opts.enableImageAlt) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const countGraphemes = (s) => [...segmenter.segment(s)].length;
const minAlt = opts.minAltGraphemes ?? { cjk: 1, default: 3 };
const maxAlt = opts.maxAltGraphemes ?? 125;
// CJK-heavy: more than half of the codepoints fall in CJK Unified / Ext A /
// Hiragana / Katakana / Compat ranges. A single character in these scripts
// carries a full word's meaning, so the junk-floor is 1 grapheme.
const isCJKHeavy = (s) => {
let cjk = 0, total = 0;
for (const ch of s) {
const cp = ch.codePointAt(0);
total++;
if ((cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3400 && cp <= 0x4DBF) ||
(cp >= 0x3040 && cp <= 0x30FF) || (cp >= 0xF900 && cp <= 0xFAFF)) cjk++;
}
return total > 0 && cjk / total > 0.5;
};
walk(root, (node) => {
if (node.type !== 'image' && node.type !== 'imageReference') return;
const alt = (node.alt || '').trim();
if (!alt) {
add('image-alt-missing', node, 'image has empty alt — intentional for purely decorative images (WCAG 1.1.1); a content image needs a description (MD045)');
return;
}
const g = countGraphemes(alt);
const floor = isCJKHeavy(alt) ? (typeof minAlt === 'object' ? minAlt.cjk : minAlt) : (typeof minAlt === 'object' ? minAlt.default : minAlt);
if (g < floor) {
add('image-alt-too-short', node, `alt "${alt}" is ${g} grapheme(s) — below the ${floor}-grapheme floor for this script; likely junk, not a description`);
} else if (g > maxAlt) {
add('image-alt-too-long', node, `alt is ${g} graphemes (limit ${maxAlt}) — alt is a label, not a caption; long descriptions belong in surrounding text or a figcaption`);
}
});
}

// ---- tables ----------------------------------------------------------------
walk(root, (node) => {
if (node.type !== 'table' || !node.children.length) return;
Expand Down
3 changes: 3 additions & 0 deletions plugin/skills/doc-structure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ Scan markdown docs for structural breakage. Report CONFIRMED findings. Fix on re
| table-ragged | row with MORE cells than the header (GitHub silently drops them) |
| ref-undefined | \[text]\[label] with no definition (renders as literal brackets) |
| def-orphan | definition never referenced |
| image-alt-missing | image with empty alt text — opt-in (default off); WCAG 1.1.1 treats empty alt as correct for decorative images, so the finding names the exception; a content image needs a description (MD045) |
| image-alt-too-short | alt below the script-aware grapheme floor (CJK: 1, default: 3) — measured in grapheme clusters so CJK/Thai/emoji count fairly |
| image-alt-too-long | alt exceeds 125 graphemes — alt is a label, not a caption (WAI concision convention) |
| bare-url | raw URL in prose (MD034 class) |
| doc-unreadable | binary/corrupted input (NUL byte sniffed) — refused before parsing, never a false "0 findings" clean bill |

Expand Down
57 changes: 57 additions & 0 deletions plugin/skills/doc-structure/lib/md-checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
// ref-undefined full/collapsed reference [text][label] with no
// definition (renders as literal brackets on GitHub)
// def-orphan a [label]: definition no reference ever uses
// image-alt-missing image with empty alt — opt-in (default off, enable via
// opts.enableImageAlt); WCAG 1.1.1 treats empty alt as
// correct for decorative images, so the finding names the
// exception (MD045 class)
// image-alt-too-short alt below the script-aware grapheme floor (CJK: 1,
// default: 3) — "img" is junk, "図" is a word
// image-alt-too-long alt exceeds 125 graphemes — alt is a label, not a
// caption (WAI concision convention)
// bare-url a raw http(s)/www URL in prose text (GFM auto-links
// it, CommonMark does not; MD034 class — style signal)
// doc-too-large pre-parse short-circuit: input over MAX_DOC_BYTES is
Expand Down Expand Up @@ -246,6 +254,55 @@ export function checkDocument(src, opts = {}) {
if (node.type === 'link' || node.type === 'image' || node.type === 'definition') checkTarget(node, node.url);
});

// ---- images: alt text (opt-in, default off) --------------------------------
// MD045 / WCAG 1.1.1. Gated by opts.enableImageAlt (default false) — in
// agent-authored or internal repos the value concentrates in public-facing
// content images, not every decorative icon; the caller (conductor / config)
// decides when to flip it on. Note: WCAG 1.1.1 itself treats empty alt as
// CORRECT for purely decorative images — the finding says so, leaving the
// judgment to the reviewing agent.
//
// Length bounds (grapheme clusters via Intl.Segmenter — zero-dependency,
// Node 16+). The floor is script-aware: a single CJK character can be a
// complete description ("図"), while "img" (3 Latin chars) is junk. The
// ceiling is language-fair with a single number (it bounds excess, not
// meaning): 125 graphemes, matching the WAI screen-reader concision
// convention (alt is a label, not a caption).
if (opts.enableImageAlt) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const countGraphemes = (s) => [...segmenter.segment(s)].length;
const minAlt = opts.minAltGraphemes ?? { cjk: 1, default: 3 };
const maxAlt = opts.maxAltGraphemes ?? 125;
// CJK-heavy: more than half of the codepoints fall in CJK Unified / Ext A /
// Hiragana / Katakana / Compat ranges. A single character in these scripts
// carries a full word's meaning, so the junk-floor is 1 grapheme.
const isCJKHeavy = (s) => {
let cjk = 0, total = 0;
for (const ch of s) {
const cp = ch.codePointAt(0);
total++;
if ((cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3400 && cp <= 0x4DBF) ||
(cp >= 0x3040 && cp <= 0x30FF) || (cp >= 0xF900 && cp <= 0xFAFF)) cjk++;
}
return total > 0 && cjk / total > 0.5;
};
walk(root, (node) => {
if (node.type !== 'image' && node.type !== 'imageReference') return;
const alt = (node.alt || '').trim();
if (!alt) {
add('image-alt-missing', node, 'image has empty alt — intentional for purely decorative images (WCAG 1.1.1); a content image needs a description (MD045)');
return;
}
const g = countGraphemes(alt);
const floor = isCJKHeavy(alt) ? (typeof minAlt === 'object' ? minAlt.cjk : minAlt) : (typeof minAlt === 'object' ? minAlt.default : minAlt);
if (g < floor) {
add('image-alt-too-short', node, `alt "${alt}" is ${g} grapheme(s) — below the ${floor}-grapheme floor for this script; likely junk, not a description`);
} else if (g > maxAlt) {
add('image-alt-too-long', node, `alt is ${g} graphemes (limit ${maxAlt}) — alt is a label, not a caption; long descriptions belong in surrounding text or a figcaption`);
}
});
}

// ---- tables ----------------------------------------------------------------
walk(root, (node) => {
if (node.type !== 'table' || !node.children.length) return;
Expand Down
2 changes: 2 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,11 +18,11 @@

| 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

Expand All @@ -31,3 +31,5 @@
Some instructions.

## Setup

![](./img/no-alt.png)
57 changes: 57 additions & 0 deletions scripts/lib/md-checks.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -27,6 +27,14 @@
// ref-undefined full/collapsed reference [text][label] with no
// definition (renders as literal brackets on GitHub)
// def-orphan a [label]: definition no reference ever uses
// image-alt-missing image with empty alt — opt-in (default off, enable via
// opts.enableImageAlt); WCAG 1.1.1 treats empty alt as
// correct for decorative images, so the finding names the
// exception (MD045 class)
// image-alt-too-short alt below the script-aware grapheme floor (CJK: 1,
// default: 3) — "img" is junk, "図" is a word
// image-alt-too-long alt exceeds 125 graphemes — alt is a label, not a
// caption (WAI concision convention)
// bare-url a raw http(s)/www URL in prose text (GFM auto-links
// it, CommonMark does not; MD034 class — style signal)
// doc-too-large pre-parse short-circuit: input over MAX_DOC_BYTES is
Expand Down Expand Up @@ -246,6 +254,55 @@ export function checkDocument(src, opts = {}) {
if (node.type === 'link' || node.type === 'image' || node.type === 'definition') checkTarget(node, node.url);
});

// ---- images: alt text (opt-in, default off) --------------------------------
// MD045 / WCAG 1.1.1. Gated by opts.enableImageAlt (default false) — in
// agent-authored or internal repos the value concentrates in public-facing
// content images, not every decorative icon; the caller (conductor / config)
// decides when to flip it on. Note: WCAG 1.1.1 itself treats empty alt as
// CORRECT for purely decorative images — the finding says so, leaving the
// judgment to the reviewing agent.
//
// Length bounds (grapheme clusters via Intl.Segmenter — zero-dependency,
// Node 16+). The floor is script-aware: a single CJK character can be a
// complete description ("図"), while "img" (3 Latin chars) is junk. The
// ceiling is language-fair with a single number (it bounds excess, not
// meaning): 125 graphemes, matching the WAI screen-reader concision
// convention (alt is a label, not a caption).
if (opts.enableImageAlt) {
const segmenter = new Intl.Segmenter('en', { granularity: 'grapheme' });
const countGraphemes = (s) => [...segmenter.segment(s)].length;
const minAlt = opts.minAltGraphemes ?? { cjk: 1, default: 3 };
const maxAlt = opts.maxAltGraphemes ?? 125;
// CJK-heavy: more than half of the codepoints fall in CJK Unified / Ext A /
// Hiragana / Katakana / Compat ranges. A single character in these scripts
// carries a full word's meaning, so the junk-floor is 1 grapheme.
const isCJKHeavy = (s) => {
let cjk = 0, total = 0;
for (const ch of s) {
const cp = ch.codePointAt(0);
total++;
if ((cp >= 0x4E00 && cp <= 0x9FFF) || (cp >= 0x3400 && cp <= 0x4DBF) ||
(cp >= 0x3040 && cp <= 0x30FF) || (cp >= 0xF900 && cp <= 0xFAFF)) cjk++;
}
return total > 0 && cjk / total > 0.5;
};
walk(root, (node) => {
if (node.type !== 'image' && node.type !== 'imageReference') return;
const alt = (node.alt || '').trim();
if (!alt) {
add('image-alt-missing', node, 'image has empty alt — intentional for purely decorative images (WCAG 1.1.1); a content image needs a description (MD045)');
return;
}
const g = countGraphemes(alt);
const floor = isCJKHeavy(alt) ? (typeof minAlt === 'object' ? minAlt.cjk : minAlt) : (typeof minAlt === 'object' ? minAlt.default : minAlt);
if (g < floor) {
add('image-alt-too-short', node, `alt "${alt}" is ${g} grapheme(s) — below the ${floor}-grapheme floor for this script; likely junk, not a description`);
} else if (g > maxAlt) {
add('image-alt-too-long', node, `alt is ${g} graphemes (limit ${maxAlt}) — alt is a label, not a caption; long descriptions belong in surrounding text or a figcaption`);
}
});
}

// ---- tables ----------------------------------------------------------------
walk(root, (node) => {
if (node.type !== 'table' || !node.children.length) return;
Expand Down
74 changes: 74 additions & 0 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
'file-missing@35', // no-alt image target also missing
'heading-duplicate@33', // second "## Setup" — anchor silently points to first
'heading-multiple-h1@5',
'heading-skip@3',
Expand Down Expand Up @@ -125,6 +126,79 @@ test('heading-duplicate prints the real Unicode anchor, not an ASCII-only slug',
}
});

// ---------------------------------------------------------------------------
// image-alt-missing (opt-in)
// ---------------------------------------------------------------------------

test('image-alt-missing: off by default — no finding even with an empty-alt image', () => {
const findings = checkDocument('![](img.png)\n');
assert.ok(!findings.some(f => f.check === 'image-alt-missing'), 'should not fire when enableImageAlt is off');
});

test('image-alt-missing: fires when enableImageAlt is true', () => {
const findings = checkDocument('![](img.png)\n', { enableImageAlt: true });
const hit = findings.find(f => f.check === 'image-alt-missing');
assert.ok(hit, 'should fire on empty alt when enabled');
assert.ok(hit.message.includes('decorative'), 'message names the WCAG decorative-image exception');
assert.ok(hit.message.includes('MD045'), 'message cites MD045');
});

test('image-alt-missing: does not fire when alt is present', () => {
const findings = checkDocument('![a diagram](img.png)\n', { enableImageAlt: true });
assert.ok(!findings.some(f => f.check === 'image-alt-missing'), 'image with alt should pass');
});

test('image-alt-missing: whitespace-only alt is treated as empty', () => {
const findings = checkDocument('![ ](img.png)\n', { enableImageAlt: true });
assert.ok(findings.some(f => f.check === 'image-alt-missing'), 'whitespace-only alt is empty');
});

test('image-alt-missing: reference images are also checked', () => {
const findings = checkDocument('![][ref]\n\n[ref]: img.png\n', { enableImageAlt: true });
assert.ok(findings.some(f => f.check === 'image-alt-missing'), 'imageReference with empty alt fires');
});

// ---- image-alt-too-short / too-long (grapheme-aware) ----

test('image-alt-too-short: Latin junk below 3-grapheme floor', () => {
const findings = checkDocument('![ab](img.png)\n', { enableImageAlt: true });
assert.ok(findings.some(f => f.check === 'image-alt-too-short'), '"ab" is 2 graphemes — below default floor');
});

test('image-alt-too-short: CJK single character is valid (floor = 1)', () => {
const findings = checkDocument('![図](img.png)\n', { enableImageAlt: true });
assert.ok(!findings.some(f => f.check === 'image-alt-too-short'), 'single CJK char is a full word');
});

test('image-alt-too-short: Thai above floor passes', () => {
const findings = checkDocument('![แผนภาพ](img.png)\n', { enableImageAlt: true });
assert.ok(!findings.some(f => f.check === 'image-alt-too-short'), 'Thai word above 3 graphemes passes');
});

test('image-alt-too-short: exactly 3 Latin graphemes passes', () => {
const findings = checkDocument('![cat](img.png)\n', { enableImageAlt: true });
assert.ok(!findings.some(f => f.check === 'image-alt-too-short'), '3 graphemes = at floor, passes');
});

test('image-alt-too-long: exceeds 125-grapheme ceiling', () => {
const longAlt = 'a'.repeat(126);
const findings = checkDocument(`![${longAlt}](img.png)\n`, { enableImageAlt: true });
assert.ok(findings.some(f => f.check === 'image-alt-too-long'), '126 graphemes exceeds ceiling');
});

test('image-alt-too-long: exactly 125 passes', () => {
const alt = 'a'.repeat(125);
const findings = checkDocument(`![${alt}](img.png)\n`, { enableImageAlt: true });
assert.ok(!findings.some(f => f.check === 'image-alt-too-long'), '125 = at ceiling, passes');
});

test('image-alt length: config-tunable min and max', () => {
const findings = checkDocument('![hi](img.png)\n', { enableImageAlt: true, minAltGraphemes: 5, maxAltGraphemes: 10 });
assert.ok(findings.some(f => f.check === 'image-alt-too-short'), '"hi" (2) below custom floor 5');
const f2 = checkDocument('![' + 'x'.repeat(11) + '](img.png)\n', { enableImageAlt: true, minAltGraphemes: 5, maxAltGraphemes: 10 });
assert.ok(f2.some(f => f.check === 'image-alt-too-long'), '11 chars above custom ceiling 10');
});

// ---------------------------------------------------------------------------
// CLI surface
// ---------------------------------------------------------------------------
Expand Down
3 changes: 3 additions & 0 deletions skills/doc-structure/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,9 @@ Scan markdown docs for structural breakage. Report CONFIRMED findings. Fix on re
| table-ragged | row with MORE cells than the header (GitHub silently drops them) |
| ref-undefined | \[text]\[label] with no definition (renders as literal brackets) |
| def-orphan | definition never referenced |
| image-alt-missing | image with empty alt text — opt-in (default off); WCAG 1.1.1 treats empty alt as correct for decorative images, so the finding names the exception; a content image needs a description (MD045) |
| image-alt-too-short | alt below the script-aware grapheme floor (CJK: 1, default: 3) — measured in grapheme clusters so CJK/Thai/emoji count fairly |
| image-alt-too-long | alt exceeds 125 graphemes — alt is a label, not a caption (WAI concision convention) |
| bare-url | raw URL in prose (MD034 class) |
| doc-unreadable | binary/corrupted input (NUL byte sniffed) — refused before parsing, never a false "0 findings" clean bill |

Expand Down
Loading