From 2337221a48edd6ff666baf239f303babd2fdf5b8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 07:50:06 +0200 Subject: [PATCH 01/14] fix(template-require-valid-alt-text): reject empty-string aria-label/aria-labelledby/alt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Before: for , , and , the rule checked only for the PRESENCE of an accessible-name fallback attribute (aria-label / aria-labelledby / alt / title). An empty-string value provides no accessible name but slipped past. Fix: add hasNonEmptyTextAttr() that requires the attribute's static value to be non-whitespace. Dynamic values (mustache, concat) remain accepted — we can't tell at lint time whether they resolve to empty. 's alt handling is unchanged — alt="" is still valid there (spec-defined marker for decorative images). Nine new invalid tests cover the three elements × three fallback attrs. --- lib/rules/template-require-valid-alt-text.js | 28 +++++++++-- .../rules/template-require-valid-alt-text.js | 47 +++++++++++++++++++ 2 files changed, 72 insertions(+), 3 deletions(-) diff --git a/lib/rules/template-require-valid-alt-text.js b/lib/rules/template-require-valid-alt-text.js index 7e7193c936..83febe0f0b 100644 --- a/lib/rules/template-require-valid-alt-text.js +++ b/lib/rules/template-require-valid-alt-text.js @@ -12,6 +12,26 @@ function hasAnyAttr(node, names) { return names.some((name) => hasAttr(node, name)); } +// For accessible-name fallback attributes (aria-label, aria-labelledby, title), +// an empty string provides no accessible name — it must be checked as "any truthy +// static value" or "any dynamic value". Returns true iff the attribute is +// present AND will meaningfully contribute an accessible name. +function hasNonEmptyTextAttr(node, name) { + const attr = findAttr(node, name); + if (!attr?.value) { + return false; + } + if (attr.value.type === 'GlimmerTextNode') { + return attr.value.chars.trim() !== ''; + } + // Mustache / concat — dynamic; assume truthy. + return true; +} + +function hasAnyNonEmptyTextAttr(node, names) { + return names.some((name) => hasNonEmptyTextAttr(node, name)); +} + function getTextValue(attr) { if (!attr?.value) { return undefined; @@ -166,7 +186,9 @@ module.exports = { return; } - if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) { + // Empty-string aria-label/aria-labelledby/alt provides no accessible + // name — require a non-empty fallback value. + if (!hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) { context.report({ node, messageId: 'inputImage' }); } @@ -177,7 +199,7 @@ module.exports = { const roleValue = getTextValue(roleAttr); if ( - hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'title']) || + hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'title']) || hasChildren(node) || (roleValue && ['presentation', 'none'].includes(roleValue)) ) { @@ -189,7 +211,7 @@ module.exports = { break; } case 'area': { - if (!hasAnyAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) { + if (!hasAnyNonEmptyTextAttr(node, ['aria-label', 'aria-labelledby', 'alt'])) { context.report({ node, messageId: 'areaMissing' }); } diff --git a/tests/lib/rules/template-require-valid-alt-text.js b/tests/lib/rules/template-require-valid-alt-text.js index be3b6d4969..d4bc426353 100644 --- a/tests/lib/rules/template-require-valid-alt-text.js +++ b/tests/lib/rules/template-require-valid-alt-text.js @@ -59,6 +59,53 @@ ruleTester.run('template-require-valid-alt-text', rule, { '', ], invalid: [ + // Empty-string aria-label / aria-labelledby / alt provides no accessible + // name. These must flag (previously accepted by a presence-only check). + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, { code: '', output: null, From 7488d8dd1a14b378f03f019f87d25d3492178e3f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 16:29:08 +0200 Subject: [PATCH 02/14] chore: drop temporal 'previously accepted' comment --- tests/lib/rules/template-require-valid-alt-text.js | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/lib/rules/template-require-valid-alt-text.js b/tests/lib/rules/template-require-valid-alt-text.js index d4bc426353..6a3723613e 100644 --- a/tests/lib/rules/template-require-valid-alt-text.js +++ b/tests/lib/rules/template-require-valid-alt-text.js @@ -60,7 +60,7 @@ ruleTester.run('template-require-valid-alt-text', rule, { ], invalid: [ // Empty-string aria-label / aria-labelledby / alt provides no accessible - // name. These must flag (previously accepted by a presence-only check). + // name, so these must flag. { code: '', output: null, From 56567ef73352f055076d56eea4bba79a020e20a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Tue, 21 Apr 2026 17:52:28 +0200 Subject: [PATCH 03/14] test: add Phase 3 audit fixture translating alt-text peer cases Translates 41 cases from peer-plugin rules: - jsx-a11y alt-text - vuejs-accessibility alt-text - lit-a11y alt-text Fixture documents parity after this fix: - Empty-string aria-label/aria-labelledby on , , and is now flagged (reusing existing objectMissing / areaMissing / inputImage messageIds). Remaining divergences ( accepting non-empty alt in jsx-a11y, without alt) are annotated inline. --- tests/audit/alt-text/peer-parity.js | 190 ++++++++++++++++++++++++++++ 1 file changed, 190 insertions(+) create mode 100644 tests/audit/alt-text/peer-parity.js diff --git a/tests/audit/alt-text/peer-parity.js b/tests/audit/alt-text/peer-parity.js new file mode 100644 index 0000000000..77a371efcc --- /dev/null +++ b/tests/audit/alt-text/peer-parity.js @@ -0,0 +1,190 @@ +// Audit fixture — peer-plugin parity for `ember/template-require-valid-alt-text`. +// See docs/audit-a11y-behavior.md for the summary of divergences. +// +// Source files: +// - context/eslint-plugin-jsx-a11y-main/__tests__/src/rules/alt-text-test.js +// - context/eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/alt-text.test.ts +// - context/eslint-plugin-lit-a11y/tests/lib/rules/alt-text.js + +'use strict'; + +const rule = require('../../../lib/rules/template-require-valid-alt-text'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('audit:alt-text (gts)', rule, { + valid: [ + // === Upstream parity (valid in jsx-a11y + ours) === + '', + '', + '', + '', + '', + // DIVERGENCE — moved to invalid below: + // '', + '', + // object with label/children + '', + '', + '', + '', + // area with label + '', + '', + '', + // input[type=image] + '', + '', + '', + + // === DIVERGENCE — aria-label/aria-labelledby on without alt === + // jsx-a11y: VALID — `` is accepted. + // vue-a11y: VALID — same. + // Our rule: INVALID — requires `alt` attribute on , full stop. + // Spec reading: the HTML spec mandates alt on . WAI-ARIA accepts + // aria-label/aria-labelledby as alternative accessible-name sources. The + // two specs disagree; we side with HTML-strict. + // No valid test here — we flag; see invalid section. + + // === Edge cases we handle === + // alt === src (we flag) + // numeric alt (we flag) + // redundant words (we flag) + ], + invalid: [ + // === Upstream parity (invalid in jsx-a11y + ours) === + { + code: '', + output: null, + errors: [{ messageId: 'imgMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, + + // === DIVERGENCE — without alt === + // jsx-a11y: VALID. Ours: INVALID (imgMissing). + // Behavior captured here; potential false positive per WAI-ARIA. + { + code: '', + output: null, + errors: [{ messageId: 'imgMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'imgMissing' }], + }, + + // === DIVERGENCE — non-empty alt with role=presentation on img === + // jsx-a11y: VALID — accepts `this is lit...`. + // Ours: INVALID — imgRolePresentation. We're spec-strict: if role is + // "none"/"presentation", the image is decorative and alt should be empty. + { + code: '', + output: null, + errors: [{ messageId: 'imgRolePresentation' }], + }, + + // === Parity — empty-string aria-label/aria-labelledby === + // jsx-a11y / vuejs-accessibility flag empty-string fallbacks on the + // "accessible-name-required" elements (, , ). Our rule now reuses the existing messageIds. + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + ], +}); + +const hbsRuleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser/hbs'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +hbsRuleTester.run('audit:alt-text (hbs)', rule, { + valid: [ + 'foo', + '', + '', + '', + '', + '', + ], + invalid: [ + { + code: '', + output: null, + errors: [{ messageId: 'imgMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, + // DIVERGENCE captured — we flag img-with-aria-label (jsx-a11y/vue-a11y don't) + { + code: '', + output: null, + errors: [{ messageId: 'imgMissing' }], + }, + // Parity — empty-string label on accessible-name-required elements. + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + ], +}); From 98e022d5f71ff3752544f4c85b25b6c35bd74149 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Wed, 22 Apr 2026 14:21:08 +0200 Subject: [PATCH 04/14] fix+docs+test: trim whitespace, tighten JSDoc, add whitespace-only coverage (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - JSDoc for hasNonEmptyTextAttr() rewritten: no longer overstates the guarantee for dynamic values, and notes that aria-labelledby IDREFs are not validated. - Added invalid-case coverage for whitespace-only aria-label / aria-labelledby / title — ACCNAME 1.2 §4.3.2 step 2D. - hasNonEmptyTextAttr() already trims static values, so the new whitespace-only cases flag without further rule changes. --- lib/rules/template-require-valid-alt-text.js | 14 ++++++++++---- .../lib/rules/template-require-valid-alt-text.js | 16 ++++++++++++++++ 2 files changed, 26 insertions(+), 4 deletions(-) diff --git a/lib/rules/template-require-valid-alt-text.js b/lib/rules/template-require-valid-alt-text.js index 83febe0f0b..db533a4e6a 100644 --- a/lib/rules/template-require-valid-alt-text.js +++ b/lib/rules/template-require-valid-alt-text.js @@ -12,10 +12,16 @@ function hasAnyAttr(node, names) { return names.some((name) => hasAttr(node, name)); } -// For accessible-name fallback attributes (aria-label, aria-labelledby, title), -// an empty string provides no accessible name — it must be checked as "any truthy -// static value" or "any dynamic value". Returns true iff the attribute is -// present AND will meaningfully contribute an accessible name. +/** + * Returns true if the named attribute is present with a non-empty, non-whitespace + * static value, OR present with a dynamic (mustache/concat) value. Dynamic values + * are assumed to resolve to a meaningful name at runtime (we can't verify at lint + * time). Static empty-string / whitespace-only values return false — per ACCNAME 1.2 + * §4.3.2 step 2D. + * + * NOTE: This does not validate that aria-labelledby IDREFs reference existing IDs. + * Rule consumers should layer that check separately if needed. + */ function hasNonEmptyTextAttr(node, name) { const attr = findAttr(node, name); if (!attr?.value) { diff --git a/tests/lib/rules/template-require-valid-alt-text.js b/tests/lib/rules/template-require-valid-alt-text.js index 6a3723613e..0f497b015f 100644 --- a/tests/lib/rules/template-require-valid-alt-text.js +++ b/tests/lib/rules/template-require-valid-alt-text.js @@ -106,6 +106,22 @@ ruleTester.run('template-require-valid-alt-text', rule, { output: null, errors: [{ messageId: 'areaMissing' }], }, + // Whitespace-only values are not valid accessible names per ACCNAME 1.2 §4.3.2 step 2D. + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'objectMissing' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'areaMissing' }], + }, { code: '', output: null, From 82022bbd1395e049dc1d4dfc139815a262785acb Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Wed, 22 Apr 2026 14:22:09 +0200 Subject: [PATCH 05/14] docs: correct audit-fixture CI-run claim (Copilot review) --- tests/audit/alt-text/peer-parity.js | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/tests/audit/alt-text/peer-parity.js b/tests/audit/alt-text/peer-parity.js index 77a371efcc..d0ca3661a6 100644 --- a/tests/audit/alt-text/peer-parity.js +++ b/tests/audit/alt-text/peer-parity.js @@ -1,4 +1,12 @@ -// Audit fixture — peer-plugin parity for `ember/template-require-valid-alt-text`. +// Audit fixture — translates peer-plugin test cases into assertions against +// our rule (`ember/template-require-valid-alt-text`). Runs as part of the +// default Vitest suite (via the `tests/**/*.js` include glob) and serves +// double-duty: (1) auditable record of peer-parity divergences, +// (2) regression coverage pinning CURRENT behavior. Each case encodes what +// OUR rule does today; divergences from upstream plugins are annotated as +// `DIVERGENCE —`. Peer-only constructs that can't be translated to Ember +// templates (JSX spread props, Vue v-bind, Angular `$event`, undefined-handler +// expression analysis) are marked `AUDIT-SKIP`. // See docs/audit-a11y-behavior.md for the summary of divergences. // // Source files: From bc642188bc45abfc651e4d5055de70dc97ccf459 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Thu, 23 Apr 2026 21:21:43 +0200 Subject: [PATCH 06/14] =?UTF-8?q?fix(template-require-valid-alt-text):=20a?= =?UTF-8?q?ddress=20Copilot=20review=20=E2=80=94=20JSDoc=20scope,=20area?= =?UTF-8?q?=20fallback=20test,=20HBS=20parity?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/rules/template-require-valid-alt-text.js | 6 ++- .../rules/template-require-valid-alt-text.js | 37 ++++++++++++++++++- 2 files changed, 40 insertions(+), 3 deletions(-) diff --git a/lib/rules/template-require-valid-alt-text.js b/lib/rules/template-require-valid-alt-text.js index db533a4e6a..a08b160bb9 100644 --- a/lib/rules/template-require-valid-alt-text.js +++ b/lib/rules/template-require-valid-alt-text.js @@ -16,8 +16,10 @@ function hasAnyAttr(node, names) { * Returns true if the named attribute is present with a non-empty, non-whitespace * static value, OR present with a dynamic (mustache/concat) value. Dynamic values * are assumed to resolve to a meaningful name at runtime (we can't verify at lint - * time). Static empty-string / whitespace-only values return false — per ACCNAME 1.2 - * §4.3.2 step 2D. + * time). Static empty-string / whitespace-only values return false — applied to + * alt / aria-label / aria-labelledby / title. The empty-name treatment aligns with + * ACCNAME 1.2 §4.3.2's aria-label step (2D), which normalizes empty/whitespace + * to "no name"; we apply the same normalization to the other fallbacks. * * NOTE: This does not validate that aria-labelledby IDREFs reference existing IDs. * Rule consumers should layer that check separately if needed. diff --git a/tests/lib/rules/template-require-valid-alt-text.js b/tests/lib/rules/template-require-valid-alt-text.js index 0f497b015f..8482ba563f 100644 --- a/tests/lib/rules/template-require-valid-alt-text.js +++ b/tests/lib/rules/template-require-valid-alt-text.js @@ -117,8 +117,11 @@ ruleTester.run('template-require-valid-alt-text', rule, { output: null, errors: [{ messageId: 'objectMissing' }], }, + // : title is NOT one of the accepted fallbacks per ACCNAME. + // Only alt / aria-label / aria-labelledby contribute. A whitespace-only + // aria-label should be flagged. { - code: '', + code: '', output: null, errors: [{ messageId: 'areaMissing' }], }, @@ -327,6 +330,38 @@ hbsRuleTester.run('template-require-valid-alt-text', rule, { }, ], }, + // HBS parity: empty / whitespace-only accessible-name fallbacks + // should be flagged, mirroring the GTS cases above. + { + code: '', + output: null, + errors: [ + { + message: + 'All elements with type="image" must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: + 'Embedded elements must have alternative text by providing inner text, aria-label or aria-labelledby attributes.', + }, + ], + }, + { + code: '', + output: null, + errors: [ + { + message: + 'Each area of an image map must have a text alternative through the `alt`, `aria-label`, or `aria-labelledby` attribute.', + }, + ], + }, { code: 'picture', output: null, From e7c6a6a5e5c5ae0d3a579a18ba201a12c35f34a8 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 24 Apr 2026 13:41:50 +0200 Subject: [PATCH 07/14] fix(#56): address round-2 Copilot review (drop unused hasAnyAttr helper) --- lib/rules/template-require-valid-alt-text.js | 4 ---- 1 file changed, 4 deletions(-) diff --git a/lib/rules/template-require-valid-alt-text.js b/lib/rules/template-require-valid-alt-text.js index a08b160bb9..8bf958a645 100644 --- a/lib/rules/template-require-valid-alt-text.js +++ b/lib/rules/template-require-valid-alt-text.js @@ -8,10 +8,6 @@ function hasAttr(node, name) { return node.attributes?.some((a) => a.name === name); } -function hasAnyAttr(node, names) { - return names.some((name) => hasAttr(node, name)); -} - /** * Returns true if the named attribute is present with a non-empty, non-whitespace * static value, OR present with a dynamic (mustache/concat) value. Dynamic values From 2abfbea4c966412633f4c940daac03c266f9c4dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 24 Apr 2026 19:05:38 +0200 Subject: [PATCH 08/14] chore(alt-text/peer-parity): drop reference to non-existent docs/audit-a11y-behavior.md (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit That doc was never checked in. Remove the dangling reference and note that divergences are captured inline (grep for 'DIVERGENCE —') plus in PR descriptions — matches how the other peer-parity fixtures describe their own divergence records. --- tests/audit/alt-text/peer-parity.js | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/tests/audit/alt-text/peer-parity.js b/tests/audit/alt-text/peer-parity.js index d0ca3661a6..8f08fd9d48 100644 --- a/tests/audit/alt-text/peer-parity.js +++ b/tests/audit/alt-text/peer-parity.js @@ -6,8 +6,9 @@ // OUR rule does today; divergences from upstream plugins are annotated as // `DIVERGENCE —`. Peer-only constructs that can't be translated to Ember // templates (JSX spread props, Vue v-bind, Angular `$event`, undefined-handler -// expression analysis) are marked `AUDIT-SKIP`. -// See docs/audit-a11y-behavior.md for the summary of divergences. +// expression analysis) are marked `AUDIT-SKIP`. Divergences are also captured +// in PR descriptions / commit messages; grepping the repo for `DIVERGENCE —` +// surfaces the full list. // // Source files: // - context/eslint-plugin-jsx-a11y-main/__tests__/src/rules/alt-text-test.js From 67de7c73fdc15c6492149b5887058793fc92d8df Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Fri, 24 Apr 2026 19:22:29 +0200 Subject: [PATCH 09/14] fix(template-require-valid-alt-text): unwrap mustache literals via shared helper (Copilot review) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Extract a new `getStaticAttrValue` util that resolves literal-valued mustaches (`{{"foo"}}`, `{{true}}`, `{{-1}}`) and single-part concat statements (`"{{true}}"`) to their static string value. `hasNonEmptyTextAttr` now delegates to the helper — `aria-label={{""}}` / `aria-label="{{""}}"` normalise to the empty string and flag the same as the text-node equivalent; genuinely dynamic values (PathExpressions, multi-part concat) still short-circuit to "assume truthy". Closes the bypass where authors wrapped an empty accessible name in mustaches. Byte-identical carrier of lib/utils/static-attr-value.js across all PRs that land it. --- lib/rules/template-require-valid-alt-text.js | 15 +- lib/utils/static-attr-value.js | 64 +++++++++ .../rules/template-require-valid-alt-text.js | 15 ++ tests/lib/utils/static-attr-value-test.js | 129 ++++++++++++++++++ 4 files changed, 219 insertions(+), 4 deletions(-) create mode 100644 lib/utils/static-attr-value.js create mode 100644 tests/lib/utils/static-attr-value-test.js diff --git a/lib/rules/template-require-valid-alt-text.js b/lib/rules/template-require-valid-alt-text.js index 8bf958a645..45c423614b 100644 --- a/lib/rules/template-require-valid-alt-text.js +++ b/lib/rules/template-require-valid-alt-text.js @@ -1,3 +1,5 @@ +const { getStaticAttrValue } = require('../utils/static-attr-value'); + const REDUNDANT_WORDS = ['image', 'photo', 'picture', 'logo', 'spacer']; function findAttr(node, name) { @@ -25,11 +27,16 @@ function hasNonEmptyTextAttr(node, name) { if (!attr?.value) { return false; } - if (attr.value.type === 'GlimmerTextNode') { - return attr.value.chars.trim() !== ''; + // Resolve mustache-literal / single-part concat forms to their static + // string via the shared helper. `aria-label={{""}}` / `aria-label="{{""}}"` + // now normalise to the empty string and are treated the same as the + // text-node empty value. + const resolved = getStaticAttrValue(attr.value); + if (resolved === undefined) { + // Genuinely dynamic — assume truthy (can't verify at lint time). + return true; } - // Mustache / concat — dynamic; assume truthy. - return true; + return resolved.trim() !== ''; } function hasAnyNonEmptyTextAttr(node, names) { diff --git a/lib/utils/static-attr-value.js b/lib/utils/static-attr-value.js new file mode 100644 index 0000000000..6499c782a9 --- /dev/null +++ b/lib/utils/static-attr-value.js @@ -0,0 +1,64 @@ +'use strict'; + +/** + * Return the statically-known string value of a Glimmer attribute value node, + * or `undefined` when the value is dynamic (cannot be resolved at lint time). + * + * Unwraps: + * - GlimmerTextNode → chars + * - GlimmerMustacheStatement with a literal path (boolean/string/number) → stringified value + * - GlimmerConcatStatement whose parts are all statically resolvable → joined string + * + * A missing/undefined value (valueless attribute, e.g. ``) + * returns the empty string. Pass `attr.value` — not the attribute itself. + */ +function getStaticAttrValue(value) { + if (value === null || value === undefined) { + return ''; + } + if (value.type === 'GlimmerTextNode') { + return value.chars; + } + if (value.type === 'GlimmerMustacheStatement') { + return extractLiteral(value.path); + } + if (value.type === 'GlimmerConcatStatement') { + const parts = value.parts || []; + let out = ''; + for (const part of parts) { + if (part.type === 'GlimmerTextNode') { + out += part.chars; + continue; + } + if (part.type === 'GlimmerMustacheStatement') { + const literal = extractLiteral(part.path); + if (literal === undefined) { + return undefined; + } + out += literal; + continue; + } + return undefined; + } + return out; + } + return undefined; +} + +function extractLiteral(path) { + if (!path) { + return undefined; + } + if (path.type === 'GlimmerBooleanLiteral') { + return path.value ? 'true' : 'false'; + } + if (path.type === 'GlimmerStringLiteral') { + return path.value; + } + if (path.type === 'GlimmerNumberLiteral') { + return String(path.value); + } + return undefined; +} + +module.exports = { getStaticAttrValue }; diff --git a/tests/lib/rules/template-require-valid-alt-text.js b/tests/lib/rules/template-require-valid-alt-text.js index 8482ba563f..4a8bc60261 100644 --- a/tests/lib/rules/template-require-valid-alt-text.js +++ b/tests/lib/rules/template-require-valid-alt-text.js @@ -57,6 +57,9 @@ ruleTester.run('template-require-valid-alt-text', rule, { '', '', '', + // Mustache-string-literal forms resolve to their static value — a + // non-empty literal supplies an accessible name the same as a text node. + '', ], invalid: [ // Empty-string aria-label / aria-labelledby / alt provides no accessible @@ -125,6 +128,18 @@ ruleTester.run('template-require-valid-alt-text', rule, { output: null, errors: [{ messageId: 'areaMissing' }], }, + // Mustache-string-literal forms that resolve to an empty string are + // treated the same as the text-node empty value — no accessible name. + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'inputImage' }], + }, { code: '', output: null, diff --git a/tests/lib/utils/static-attr-value-test.js b/tests/lib/utils/static-attr-value-test.js new file mode 100644 index 0000000000..ea9a4f30c6 --- /dev/null +++ b/tests/lib/utils/static-attr-value-test.js @@ -0,0 +1,129 @@ +'use strict'; + +const { getStaticAttrValue } = require('../../../lib/utils/static-attr-value'); + +describe('getStaticAttrValue', () => { + it('returns empty string for null/undefined (valueless attribute)', () => { + expect(getStaticAttrValue(null)).toBe(''); + expect(getStaticAttrValue(undefined)).toBe(''); + }); + + it('returns chars for GlimmerTextNode', () => { + expect(getStaticAttrValue({ type: 'GlimmerTextNode', chars: 'hello' })).toBe('hello'); + expect(getStaticAttrValue({ type: 'GlimmerTextNode', chars: '' })).toBe(''); + }); + + it('unwraps GlimmerMustacheStatement with BooleanLiteral', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerBooleanLiteral', value: true }, + }) + ).toBe('true'); + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerBooleanLiteral', value: false }, + }) + ).toBe('false'); + }); + + it('unwraps GlimmerMustacheStatement with StringLiteral', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerStringLiteral', value: 'foo' }, + }) + ).toBe('foo'); + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerStringLiteral', value: '' }, + }) + ).toBe(''); + }); + + it('unwraps GlimmerMustacheStatement with NumberLiteral', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerNumberLiteral', value: -1 }, + }) + ).toBe('-1'); + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerNumberLiteral', value: 0 }, + }) + ).toBe('0'); + }); + + it('returns undefined for GlimmerMustacheStatement with a dynamic PathExpression', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerPathExpression', original: 'this.foo' }, + }) + ).toBeUndefined(); + }); + + it('joins GlimmerConcatStatement with only static parts', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerConcatStatement', + parts: [ + { type: 'GlimmerTextNode', chars: 'prefix-' }, + { + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerStringLiteral', value: 'mid' }, + }, + { type: 'GlimmerTextNode', chars: '-suffix' }, + ], + }) + ).toBe('prefix-mid-suffix'); + }); + + it('joins concat with boolean and number literal parts', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerConcatStatement', + parts: [ + { + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerBooleanLiteral', value: true }, + }, + ], + }) + ).toBe('true'); + expect( + getStaticAttrValue({ + type: 'GlimmerConcatStatement', + parts: [ + { + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerNumberLiteral', value: -1 }, + }, + ], + }) + ).toBe('-1'); + }); + + it('returns undefined for GlimmerConcatStatement with a dynamic part', () => { + expect( + getStaticAttrValue({ + type: 'GlimmerConcatStatement', + parts: [ + { type: 'GlimmerTextNode', chars: 'x-' }, + { + type: 'GlimmerMustacheStatement', + path: { type: 'GlimmerPathExpression', original: 'this.foo' }, + }, + ], + }) + ).toBeUndefined(); + }); + + it('returns undefined for an unknown node type', () => { + expect(getStaticAttrValue({ type: 'GlimmerSubExpression' })).toBeUndefined(); + }); +}); From 6101f280f8279bd84830eac2ad8feb7a60282307 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 25 Apr 2026 06:45:43 +0200 Subject: [PATCH 10/14] test(template-require-valid-alt-text): absorb audit-fixture cases, drop audit fixture MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Upstream maintainers don't want the per-PR `tests/audit/peer-parity` pattern. Port one case that pinned distinct behavior: - ` ` as VALID — whitespace-only alt is currently treated as decorative; jsx-a11y agrees. All other audit cases were already covered by the regular tests on this branch (extensive existing coverage of object/area/input variants, empty aria-label/labelledby, presentation-role conflicts, etc). --- tests/audit/alt-text/peer-parity.js | 199 ------------------ .../rules/template-require-valid-alt-text.js | 3 + 2 files changed, 3 insertions(+), 199 deletions(-) delete mode 100644 tests/audit/alt-text/peer-parity.js diff --git a/tests/audit/alt-text/peer-parity.js b/tests/audit/alt-text/peer-parity.js deleted file mode 100644 index 8f08fd9d48..0000000000 --- a/tests/audit/alt-text/peer-parity.js +++ /dev/null @@ -1,199 +0,0 @@ -// Audit fixture — translates peer-plugin test cases into assertions against -// our rule (`ember/template-require-valid-alt-text`). Runs as part of the -// default Vitest suite (via the `tests/**/*.js` include glob) and serves -// double-duty: (1) auditable record of peer-parity divergences, -// (2) regression coverage pinning CURRENT behavior. Each case encodes what -// OUR rule does today; divergences from upstream plugins are annotated as -// `DIVERGENCE —`. Peer-only constructs that can't be translated to Ember -// templates (JSX spread props, Vue v-bind, Angular `$event`, undefined-handler -// expression analysis) are marked `AUDIT-SKIP`. Divergences are also captured -// in PR descriptions / commit messages; grepping the repo for `DIVERGENCE —` -// surfaces the full list. -// -// Source files: -// - context/eslint-plugin-jsx-a11y-main/__tests__/src/rules/alt-text-test.js -// - context/eslint-plugin-vuejs-accessibility-main/src/rules/__tests__/alt-text.test.ts -// - context/eslint-plugin-lit-a11y/tests/lib/rules/alt-text.js - -'use strict'; - -const rule = require('../../../lib/rules/template-require-valid-alt-text'); -const RuleTester = require('eslint').RuleTester; - -const ruleTester = new RuleTester({ - parser: require.resolve('ember-eslint-parser'), - parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, -}); - -ruleTester.run('audit:alt-text (gts)', rule, { - valid: [ - // === Upstream parity (valid in jsx-a11y + ours) === - '', - '', - '', - '', - '', - // DIVERGENCE — moved to invalid below: - // '', - '', - // object with label/children - '', - '', - '', - '', - // area with label - '', - '', - '', - // input[type=image] - '', - '', - '', - - // === DIVERGENCE — aria-label/aria-labelledby on without alt === - // jsx-a11y: VALID — `` is accepted. - // vue-a11y: VALID — same. - // Our rule: INVALID — requires `alt` attribute on , full stop. - // Spec reading: the HTML spec mandates alt on . WAI-ARIA accepts - // aria-label/aria-labelledby as alternative accessible-name sources. The - // two specs disagree; we side with HTML-strict. - // No valid test here — we flag; see invalid section. - - // === Edge cases we handle === - // alt === src (we flag) - // numeric alt (we flag) - // redundant words (we flag) - ], - invalid: [ - // === Upstream parity (invalid in jsx-a11y + ours) === - { - code: '', - output: null, - errors: [{ messageId: 'imgMissing' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'inputImage' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'objectMissing' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'areaMissing' }], - }, - - // === DIVERGENCE — without alt === - // jsx-a11y: VALID. Ours: INVALID (imgMissing). - // Behavior captured here; potential false positive per WAI-ARIA. - { - code: '', - output: null, - errors: [{ messageId: 'imgMissing' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'imgMissing' }], - }, - - // === DIVERGENCE — non-empty alt with role=presentation on img === - // jsx-a11y: VALID — accepts `this is lit...`. - // Ours: INVALID — imgRolePresentation. We're spec-strict: if role is - // "none"/"presentation", the image is decorative and alt should be empty. - { - code: '', - output: null, - errors: [{ messageId: 'imgRolePresentation' }], - }, - - // === Parity — empty-string aria-label/aria-labelledby === - // jsx-a11y / vuejs-accessibility flag empty-string fallbacks on the - // "accessible-name-required" elements (, , ). Our rule now reuses the existing messageIds. - { - code: '', - output: null, - errors: [{ messageId: 'objectMissing' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'objectMissing' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'areaMissing' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'areaMissing' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'inputImage' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'inputImage' }], - }, - ], -}); - -const hbsRuleTester = new RuleTester({ - parser: require.resolve('ember-eslint-parser/hbs'), - parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, -}); - -hbsRuleTester.run('audit:alt-text (hbs)', rule, { - valid: [ - 'foo', - '', - '', - '', - '', - '', - ], - invalid: [ - { - code: '', - output: null, - errors: [{ messageId: 'imgMissing' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'inputImage' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'objectMissing' }], - }, - { - code: '', - output: null, - errors: [{ messageId: 'areaMissing' }], - }, - // DIVERGENCE captured — we flag img-with-aria-label (jsx-a11y/vue-a11y don't) - { - code: '', - output: null, - errors: [{ messageId: 'imgMissing' }], - }, - // Parity — empty-string label on accessible-name-required elements. - { - code: '', - output: null, - errors: [{ messageId: 'objectMissing' }], - }, - ], -}); diff --git a/tests/lib/rules/template-require-valid-alt-text.js b/tests/lib/rules/template-require-valid-alt-text.js index 4a8bc60261..2c00947593 100644 --- a/tests/lib/rules/template-require-valid-alt-text.js +++ b/tests/lib/rules/template-require-valid-alt-text.js @@ -12,6 +12,9 @@ ruleTester.run('template-require-valid-alt-text', rule, { '', '', '', + // Whitespace-only alt — pin our current behavior. Peer plugins + // (jsx-a11y) accept this; we don't trim before considering "empty alt". + '', '', '', From 19e44c40ec3026d1817e39167b240b537540ad2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Sat, 25 Apr 2026 08:15:28 +0200 Subject: [PATCH 11/14] =?UTF-8?q?BUGFIX:=20template-require-iframe-title?= =?UTF-8?q?=20=E2=80=94=20flag=20invalid=20title=20literals=20+=20add=20al?= =?UTF-8?q?lowWhitespaceOnlyTitle=20opt-out?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Treat literal AST values that don't produce a meaningful accessible name as invalid `