diff --git a/plugin/scripts/lib/md-checks.mjs b/plugin/scripts/lib/md-checks.mjs index 5cf5759..532af57 100644 --- a/plugin/scripts/lib/md-checks.mjs +++ b/plugin/scripts/lib/md-checks.mjs @@ -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 @@ -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; diff --git a/plugin/skills/doc-structure/SKILL.md b/plugin/skills/doc-structure/SKILL.md index e27a13c..eb821bb 100644 --- a/plugin/skills/doc-structure/SKILL.md +++ b/plugin/skills/doc-structure/SKILL.md @@ -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 | diff --git a/plugin/skills/doc-structure/lib/md-checks.mjs b/plugin/skills/doc-structure/lib/md-checks.mjs index 5cf5759..532af57 100644 --- a/plugin/skills/doc-structure/lib/md-checks.mjs +++ b/plugin/skills/doc-structure/lib/md-checks.mjs @@ -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 @@ -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; 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..532af57 100644 --- a/scripts/lib/md-checks.mjs +++ b/scripts/lib/md-checks.mjs @@ -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 @@ -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; diff --git a/scripts/lib/md-checks.test.mjs b/scripts/lib/md-checks.test.mjs index 1215541..358256d 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,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 // --------------------------------------------------------------------------- diff --git a/skills/doc-structure/SKILL.md b/skills/doc-structure/SKILL.md index e27a13c..eb821bb 100644 --- a/skills/doc-structure/SKILL.md +++ b/skills/doc-structure/SKILL.md @@ -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 |