Skip to content

Commit 5b850b2

Browse files
committed
fix(template-require-mandatory-role-attributes): skip {{input}} semantic-role exemption in strict mode (Copilot review)
In classic Handlebars (.hbs), `{{input}}` globally resolves to Ember's built-in input helper, which renders a native `<input>` — so the semantic-role exemption (e.g., `role="switch"` on `{{input type="checkbox"}}` is satisfied by the native `checked` property) is correct. In strict-mode GJS/GTS there is no lowercase `input` export from `@ember/component` (only the PascalCase `<Input>` component), so `{{input}}` in strict mode is always a user-bound identifier. We can't prove it renders a native `<input>`, so applying the exemption risks silently skipping required-ARIA checks on arbitrary components. Detect strict mode by filename (matching the convention already established by template-builtin-component-arguments) and return null for `{{input}}` there; the regular required-ARIA flow then reports missing attributes.
1 parent 4343d81 commit 5b850b2

2 files changed

Lines changed: 48 additions & 16 deletions

File tree

lib/rules/template-require-mandatory-role-attributes.js

Lines changed: 24 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -53,22 +53,30 @@ function getStaticAttrValue(node, name) {
5353
return undefined;
5454
}
5555

56-
function getTagName(node) {
56+
// In classic Handlebars (.hbs) `{{input}}` globally resolves to Ember's
57+
// built-in input helper, which renders a native <input>. In strict-mode
58+
// GJS/GTS there is no corresponding lowercase `input` export from
59+
// `@ember/component` (only the PascalCase `<Input>` component), so
60+
// `{{input}}` in strict mode is always a user-bound identifier and cannot
61+
// be assumed to render a native <input>. Treating it as native there
62+
// would silently skip required-ARIA checks on arbitrary components.
63+
function isClassicHbsFilename(context) {
64+
const filename = context.filename || context.getFilename?.() || '';
65+
return !filename.endsWith('.gjs') && !filename.endsWith('.gts');
66+
}
67+
68+
function getTagName(node, context) {
5769
if (node?.type === 'GlimmerElementNode') {
5870
// HTML tag names are case-insensitive; normalize so <INPUT>/<Input> match
5971
// the lowercase keys in AX_CONCEPTS_BY_TAG and the semantic-role maps.
6072
return node.tag?.toLowerCase();
6173
}
6274
if (node?.type === 'GlimmerMustacheStatement' && node.path?.original === 'input') {
63-
// The classic `{{input}}` helper renders a native <input>.
64-
//
65-
// Caveat: in strict GJS/GTS mode, `{{input}}` is whatever was imported
66-
// under the name `input` — it could be the classic helper (still renders
67-
// native <input>) or some user-defined component. We assume the classic
68-
// helper; the false-positive rate in practice is low because strict-mode
69-
// authors rarely use `{{input}}` at all (idiomatic is <input> or
70-
// <Input>), and when they do, it's almost always the imported built-in.
71-
return 'input';
75+
if (!context || isClassicHbsFilename(context)) {
76+
return 'input';
77+
}
78+
// Strict-mode {{input}} — not the classic helper, can't claim native.
79+
return null;
7280
}
7381
return null;
7482
}
@@ -111,8 +119,8 @@ function buildAxConceptsByTag() {
111119
return index;
112120
}
113121

114-
function isSemanticRoleElement(node, role) {
115-
const tag = getTagName(node);
122+
function isSemanticRoleElement(node, role, context) {
123+
const tag = getTagName(node, context);
116124
if (!tag || typeof role !== 'string') {
117125
return false;
118126
}
@@ -153,14 +161,14 @@ function isSemanticRoleElement(node, role) {
153161
// type=checkbox role=switch>), the element is exempt: return { role, missing: null }.
154162
//
155163
// Diverges from jsx-a11y, which validates every recognised token.
156-
function getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node) {
164+
function getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node, context) {
157165
for (const role of roleTokens) {
158166
const roleDefinition = roles.get(role);
159167
if (!roleDefinition || roleDefinition.abstract) {
160168
continue;
161169
}
162170
// Semantic-role elements expose required ARIA state natively — skip.
163-
if (isSemanticRoleElement(node, role)) {
171+
if (isSemanticRoleElement(node, role, context)) {
164172
return { role, missing: null };
165173
}
166174
const requiredAttributes = Object.keys(roleDefinition.requiredProps);
@@ -227,7 +235,7 @@ module.exports = {
227235
.filter((attribute) => attribute.name?.startsWith('aria-'))
228236
.map((attribute) => attribute.name);
229237

230-
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node);
238+
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node, context);
231239

232240
if (result?.missing) {
233241
reportMissingAttributes(node, result.role, result.missing);
@@ -245,7 +253,7 @@ module.exports = {
245253
.filter((pair) => pair.key.startsWith('aria-'))
246254
.map((pair) => pair.key);
247255

248-
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node);
256+
const result = getMissingRequiredAttributes(roleTokens, foundAriaAttributes, node, context);
249257

250258
if (result?.missing) {
251259
reportMissingAttributes(node, result.role, result.missing);

tests/lib/rules/template-require-mandatory-role-attributes.js

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -193,6 +193,30 @@ ruleTester.run('template-require-mandatory-role-attributes', rule, {
193193
},
194194
],
195195
},
196+
// Strict-mode {{input}} is a scope binding, not Ember's classic helper
197+
// (which doesn't exist as a strict-mode export from @ember/component).
198+
// The semantic-role exemption must NOT apply — we can't prove the
199+
// imported identifier renders a native <input>. Flag the missing ARIA.
200+
{
201+
code: '<template>{{input type="checkbox" role="switch"}}</template>',
202+
filename: 'component.gjs',
203+
output: null,
204+
errors: [
205+
{
206+
message: 'The attribute aria-checked is required by the role switch',
207+
},
208+
],
209+
},
210+
{
211+
code: '<template>{{input type="range" role="slider"}}</template>',
212+
filename: 'component.gts',
213+
output: null,
214+
errors: [
215+
{
216+
message: 'The attribute aria-valuenow is required by the role slider',
217+
},
218+
],
219+
},
196220
],
197221
});
198222

0 commit comments

Comments
 (0)