From fa59cf390b758b808cf30d3d925e1d0fce3ce330 Mon Sep 17 00:00:00 2001 From: mehvetero <270047+mehvetero@users.noreply.github.com> Date: Wed, 29 Jul 2026 16:42:18 +0530 Subject: [PATCH 1/2] =?UTF-8?q?feat:=20image-alt-missing=20check=20?= =?UTF-8?q?=E2=80=94=20opt-in,=20WCAG-aware=20(MD045)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Images with empty alt are inaccessible to screen readers and invisible to search engines. The AST already carries the alt field on both image and imageReference nodes. Gated by opts.enableImageAlt (default false) — the caller (conductor / config) decides when to flip it on. In agent-authored or internal repos the value concentrates in public-facing content images; nudging every image costs tokens for little value. WCAG 1.1.1 treats empty alt as correct for purely decorative images. The finding message says so: "intentional for purely decorative images (WCAG 1.1.1); a content image needs a description (MD045)" — leaving the judgment to the reviewing agent, not claiming blanket harm. Fixture: planted ![](./img/no-alt.png) at L35 in defects-structure.md. Default run (enableImageAlt=false): image-alt-missing does NOT fire — existing defect-count expectations unchanged (only file-missing@35 added for the new image target). Five dedicated tests: - off by default (no finding even with empty alt) - fires when enabled - silent when alt is present - whitespace-only alt treated as empty - reference images covered SKILL.md: added image-alt-missing row noting opt-in default and the WCAG decorative-image exception. 142/142 pass, verify PASS, dist rebuilt. --- plugin/scripts/lib/md-checks.mjs | 21 ++++++++++++ plugin/skills/doc-structure/SKILL.md | 1 + plugin/skills/doc-structure/lib/md-checks.mjs | 21 ++++++++++++ scripts/fixtures/defects-structure.md | 2 ++ scripts/lib/md-checks.mjs | 21 ++++++++++++ scripts/lib/md-checks.test.mjs | 33 +++++++++++++++++++ skills/doc-structure/SKILL.md | 1 + 7 files changed, 100 insertions(+) diff --git a/plugin/scripts/lib/md-checks.mjs b/plugin/scripts/lib/md-checks.mjs index 5cf5759..4932eff 100644 --- a/plugin/scripts/lib/md-checks.mjs +++ b/plugin/scripts/lib/md-checks.mjs @@ -27,6 +27,10 @@ // 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) // 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 @@ -246,6 +250,23 @@ 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. + if (opts.enableImageAlt) { + 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)'); + } + }); + } + // ---- tables ---------------------------------------------------------------- walk(root, (node) => { if (node.type !== 'table' || !node.children.length) return; diff --git a/plugin/skills/doc-structure/SKILL.md b/plugin/skills/doc-structure/SKILL.md index e27a13c..72dd463 100644 --- a/plugin/skills/doc-structure/SKILL.md +++ b/plugin/skills/doc-structure/SKILL.md @@ -36,6 +36,7 @@ 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) | | 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 | diff --git a/plugin/skills/doc-structure/lib/md-checks.mjs b/plugin/skills/doc-structure/lib/md-checks.mjs index 5cf5759..4932eff 100644 --- a/plugin/skills/doc-structure/lib/md-checks.mjs +++ b/plugin/skills/doc-structure/lib/md-checks.mjs @@ -27,6 +27,10 @@ // 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) // 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 @@ -246,6 +250,23 @@ 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. + if (opts.enableImageAlt) { + 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)'); + } + }); + } + // ---- tables ---------------------------------------------------------------- walk(root, (node) => { if (node.type !== 'table' || !node.children.length) return; diff --git a/scripts/fixtures/defects-structure.md b/scripts/fixtures/defects-structure.md index 0bd49d5..d510736 100644 --- a/scripts/fixtures/defects-structure.md +++ b/scripts/fixtures/defects-structure.md @@ -31,3 +31,5 @@ Bare URL: https://example.com/dangling in prose. Some instructions. ## Setup + +![](./img/no-alt.png) diff --git a/scripts/lib/md-checks.mjs b/scripts/lib/md-checks.mjs index 5cf5759..4932eff 100644 --- a/scripts/lib/md-checks.mjs +++ b/scripts/lib/md-checks.mjs @@ -27,6 +27,10 @@ // 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) // 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 @@ -246,6 +250,23 @@ 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. + if (opts.enableImageAlt) { + 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)'); + } + }); + } + // ---- tables ---------------------------------------------------------------- walk(root, (node) => { if (node.type !== 'table' || !node.children.length) return; diff --git a/scripts/lib/md-checks.test.mjs b/scripts/lib/md-checks.test.mjs index 1215541..8c85623 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 + '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', @@ -125,6 +126,38 @@ 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'); +}); + // --------------------------------------------------------------------------- // CLI surface // --------------------------------------------------------------------------- diff --git a/skills/doc-structure/SKILL.md b/skills/doc-structure/SKILL.md index e27a13c..72dd463 100644 --- a/skills/doc-structure/SKILL.md +++ b/skills/doc-structure/SKILL.md @@ -36,6 +36,7 @@ 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) | | 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 | From 4ffc87c41fbe25b4e8f1dcb0990ae36c4440580e Mon Sep 17 00:00:00 2001 From: mehvetero <270047+mehvetero@users.noreply.github.com> Date: Wed, 29 Jul 2026 17:28:33 +0530 Subject: [PATCH 2/2] feat: grapheme-aware alt length bounds (min + max) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Owner requirements from #11 addenda: a junk floor and an excess ceiling, both language-fair. Floor (image-alt-too-short): Script-aware via grapheme clusters (Intl.Segmenter, Node 16+ built-in). CJK: 1 grapheme (a single character is a full word — "図"). Default: 3 graphemes ("img" is junk, "cat" is a word). Config-tunable via opts.minAltGraphemes (number or { cjk, default }). Ceiling (image-alt-too-long): 125 graphemes — WAI screen-reader concision convention. Alt is a label, not a caption; long descriptions belong in surrounding text or figcaption. Config-tunable via opts.maxAltGraphemes. Both language-fair: the floor uses script classification (CJK Unified / Ext A / Hiragana / Katakana ranges, >50% = CJK-heavy); the ceiling uses a single number (bounding excess is script-neutral). 7 new tests: Latin junk, CJK single char, Thai above floor, exact floor, above ceiling, exact ceiling, config-tunable overrides. 149/149 pass, verify PASS, dist rebuilt. --- plugin/scripts/lib/md-checks.mjs | 36 ++++++++++++++++ plugin/skills/doc-structure/SKILL.md | 2 + plugin/skills/doc-structure/lib/md-checks.mjs | 36 ++++++++++++++++ scripts/lib/md-checks.mjs | 36 ++++++++++++++++ scripts/lib/md-checks.test.mjs | 41 +++++++++++++++++++ skills/doc-structure/SKILL.md | 2 + 6 files changed, 153 insertions(+) diff --git a/plugin/scripts/lib/md-checks.mjs b/plugin/scripts/lib/md-checks.mjs index 4932eff..532af57 100644 --- a/plugin/scripts/lib/md-checks.mjs +++ b/plugin/scripts/lib/md-checks.mjs @@ -31,6 +31,10 @@ // 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 @@ -257,12 +261,44 @@ export function checkDocument(src, opts = {}) { // 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`); } }); } diff --git a/plugin/skills/doc-structure/SKILL.md b/plugin/skills/doc-structure/SKILL.md index 72dd463..eb821bb 100644 --- a/plugin/skills/doc-structure/SKILL.md +++ b/plugin/skills/doc-structure/SKILL.md @@ -37,6 +37,8 @@ Scan markdown docs for structural breakage. Report CONFIRMED findings. Fix on re | 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 | diff --git a/plugin/skills/doc-structure/lib/md-checks.mjs b/plugin/skills/doc-structure/lib/md-checks.mjs index 4932eff..532af57 100644 --- a/plugin/skills/doc-structure/lib/md-checks.mjs +++ b/plugin/skills/doc-structure/lib/md-checks.mjs @@ -31,6 +31,10 @@ // 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 @@ -257,12 +261,44 @@ export function checkDocument(src, opts = {}) { // 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`); } }); } diff --git a/scripts/lib/md-checks.mjs b/scripts/lib/md-checks.mjs index 4932eff..532af57 100644 --- a/scripts/lib/md-checks.mjs +++ b/scripts/lib/md-checks.mjs @@ -31,6 +31,10 @@ // 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 @@ -257,12 +261,44 @@ export function checkDocument(src, opts = {}) { // 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`); } }); } diff --git a/scripts/lib/md-checks.test.mjs b/scripts/lib/md-checks.test.mjs index 8c85623..358256d 100644 --- a/scripts/lib/md-checks.test.mjs +++ b/scripts/lib/md-checks.test.mjs @@ -158,6 +158,47 @@ test('image-alt-missing: reference images are also checked', () => { 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 // --------------------------------------------------------------------------- diff --git a/skills/doc-structure/SKILL.md b/skills/doc-structure/SKILL.md index 72dd463..eb821bb 100644 --- a/skills/doc-structure/SKILL.md +++ b/skills/doc-structure/SKILL.md @@ -37,6 +37,8 @@ Scan markdown docs for structural breakage. Report CONFIRMED findings. Fix on re | 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 |