diff --git a/.release-plan.json b/.release-plan.json index 83a94f63a8..a24cc968e3 100644 --- a/.release-plan.json +++ b/.release-plan.json @@ -1,22 +1,22 @@ { "solution": { "eslint-plugin-ember": { - "impact": "patch", - "oldVersion": "13.1.2", - "newVersion": "13.1.3", + "impact": "minor", + "oldVersion": "13.1.4", + "newVersion": "13.2.0", "tagName": "latest", "constraints": [ { - "impact": "patch", - "reason": "Appears in changelog section :bug: Bug Fix" + "impact": "minor", + "reason": "Appears in changelog section :rocket: Enhancement" }, { "impact": "patch", - "reason": "Appears in changelog section :house: Internal" + "reason": "Appears in changelog section :bug: Bug Fix" } ], "pkgJSONPath": "./package.json" } }, - "description": "## Release (2026-04-25)\n\n* eslint-plugin-ember 13.1.3 (patch)\n\n#### :bug: Bug Fix\n* `eslint-plugin-ember`\n * [#2730](https://github.com/ember-cli/eslint-plugin-ember/pull/2730) BUGFIX: template-require-valid-alt-text — reject empty-string aria-label/labelledby/alt on , , ([@johanrd](https://github.com/johanrd))\n * [#2729](https://github.com/ember-cli/eslint-plugin-ember/pull/2729) BUGFIX: template-no-invalid-role — support DPUB/Graphics-ARIA and role-fallback lists ([@johanrd](https://github.com/johanrd))\n * [#2726](https://github.com/ember-cli/eslint-plugin-ember/pull/2726) BUGFIX: template-no-unsupported-role-attributes — honor aria-query attribute constraints ([@johanrd](https://github.com/johanrd))\n * [#2727](https://github.com/ember-cli/eslint-plugin-ember/pull/2727) BUGFIX: template-no-redundant-role — case-insensitive match + ` + `disabled` — boolean HTML attribute, reflecting + +Same boolean-coercion behavior as `muted` for the bare-mustache form, but `disabled` reflects: when the IDL `disabled` is set, the HTML attribute appears as `disabled=""`. + +| ID | Source | outerHTML | IDL `disabled` | hasAttr | At runtime | +| --- | ---------------------------------- | -------------------------- | -------------- | ------- | -------------------------------------- | +| d1 | `` | `` | `true` | `true` | **disabled ON** | +| d2 | `` | `` | `true` | `true` | **disabled ON** | +| d3 | `` | `` | `false` | `false` | **disabled OFF** | +| d4 | `` | `` | `true` | `true` | **disabled ON** | +| d5 | `` | `` | `true` | `true` | **disabled ON** | +| d6 | `` | `` | `false` | `false` | **disabled OFF** | +| d7 | `` | `` | `true` | `true` | **disabled ON** (concat sets IDL true) | +| d8 | `` | `` | `true` | `true` | **disabled ON** | +| d9 | `` | `` | `true` | `true` | **disabled ON** | +| d10 | `` | `` | `true` | `true` | **disabled ON** | + +**Lint truth for `disabled`:** identical falsy-set as `muted` — bare `{{false}}` / `{{null}}` (and by inference `{{undefined}}` / `{{0}}`, not yet tested for this attribute). Every other form is ON. The HTML serialization differs from `muted` (because `disabled` reflects), but the boolean lint truth is the same. + +### `
` + `aria-hidden` — ARIA string attribute + +ARIA attributes are string-valued, but Glimmer's bare-mustache form applies the same falsy-coercion as for boolean HTML attributes (omit on `false`/`null`/`undefined`). Bare-string and concat forms render the value literally — concat does **not** coerce to a boolean here. The "At runtime (per ARIA spec)" column derives whether the element is hidden from assistive tech: `aria-hidden="true"` is hidden; `aria-hidden="false"` is visible; `aria-hidden=""` (and the implied default) is contested. + +| ID | Source | outerHTML | IDL `ariaHidden` | hasAttr | At runtime (ARIA) | +| --- | --------------------------------------- | --------------------------------- | ---------------- | ------- | ---------------------------------------------- | +| h1 | `
` | `
` | `""` | `true` | **contested** (empty value) | +| h2 | `
` | `
` | `""` | `true` | **contested** | +| h3 | `` | `` | `"true"` | `true` | **hidden** | +| h4 | `
` | `
` | `"false"` | `true` | **visible** | +| h5 | `
` | `
` | `""` | `true` | **contested** (rendered empty, _not_ `"true"`) | +| h6 | `
` | `
` | `null` | `false` | **visible** (default) | +| h7 | `
` | `` | `"true"` | `true` | **hidden** | +| h8 | `
` | `
` | `"false"` | `true` | **visible** | +| h9 | `
` | `
` | `null` | `false` | **visible** | +| h10 | `
` | `
` | `null` | `false` | **visible** | +| h11 | `
` | `
` | `""` | `true` | **contested** | +| h12 | `
` | `` | `"true"` | `true` | **hidden** | +| h13 | `
` | `
` | `"false"` | `true` | **visible** | +| h14 | `
` | `` | `"true"` | `true` | **hidden** | +| h15 | `
` | `
` | `"false"` | `true` | **visible** | + +**Lint truth for `aria-hidden`:** the rule depends on the value, not just presence. Notable differences from boolean attrs: bare `{{true}}` renders as `aria-hidden=""` (contested, not `"true"`); concat `="{{false}}"` renders as `aria-hidden="false"` (visible — _not_ IDL-coerced like boolean attrs). + +### `
` + `tabindex` — numeric attribute + +Falsy-coercion (`false`/`null`) omits, like boolean attrs. Numeric and string-numeric values render the literal. IDL `tabIndex` returns `-1` when no attribute is set (the default for non-focusable elements), so `hasAttr` is the cleaner signal for "tabindex is set" than checking `tabIndex`. + +| ID | Source | outerHTML | IDL `tabIndex` | hasAttr | Effective | +| --- | -------------------------------- | --------------------------- | -------------- | ------- | ----------- | +| t1 | `
` | `
` | `0` | `true` | tabindex 0 | +| t2 | `
` | `
` | `-1` | `true` | tabindex -1 | +| t3 | `
` | `
` | `1` | `true` | tabindex 1 | +| t4 | `
` | `
` | `0` | `true` | tabindex 0 | +| t5 | `
` | `
` | `0` | `true` | tabindex 0 | +| t6 | `
` | `
` | `-1` (default) | `false` | not set | +| t7 | `
` | `
` | `-1` (default) | `false` | not set | + +**Lint truth for `tabindex`:** rules that care about the value should extract the literal from the AST (the value is preserved as-written across all bare and concat literal forms). Rules that care about presence should check that the source is not bare `{{false}}` / `{{null}}` (and by inference `{{undefined}}`, not tested). + +### `` + `autocomplete` — string attribute (not boolean-coerced) + +A regular string attribute. Glimmer's bare-mustache **does not** apply falsy-coercion here — `autocomplete={{false}}` renders as `autocomplete="false"` (kept). This is the key difference from boolean HTML attrs and from `aria-*`. The IDL `el.autocomplete` canonicalizes the attribute value (returns `""` for invalid tokens), so it differs from `getAttribute('autocomplete')` for non-spec values. + +| ID | Source | outerHTML | IDL `autocomplete` | hasAttr | attrValue | +| --- | ------------------------------------ | ------------------------------ | -------------------- | ------- | --------- | +| i1 | `` | `` | `"off"` | `true` | `"off"` | +| i2 | `` | `` | `"off"` | `true` | `"off"` | +| i3 | `` | `` | `"off"` | `true` | `"off"` | +| i4 | `` | `` | `""` (canonicalized) | `true` | `"false"` | +| i5 | `` | `` | `""` (canonicalized) | `true` | `"false"` | + +**Lint truth for `autocomplete`:** rules should check `getAttribute('autocomplete')` (or its AST equivalent) against the spec's valid token list, not the IDL property. The bare-mustache `{{false}}` form will give a literal `"false"` value — almost certainly an authoring bug worth flagging. + +### Cross-attribute observations + +- **Glimmer's bare-mustache "boolean coercion" list.** For `muted` (HTML boolean), `disabled` (HTML boolean), `aria-hidden` (ARIA string), and `tabindex` (numeric), bare `{{false}}` / `{{null}}` / `{{undefined}}` (and `{{0}}` for `muted`) cause the attribute to be **omitted**. For `autocomplete` (plain string), bare `{{false}}` renders as `autocomplete="false"`. So Glimmer applies boolean-coercion to a known set — at minimum HTML boolean attrs, ARIA attrs, and numeric attrs. Plain string attrs do not get coerced. +- **Bare-mustache string literals never coerce.** `attr={{"false"}}`, `attr={{"true"}}`, `attr={{""}}` always render as `attr=""` for every attribute kind tested. The literal `"false"` is JS-truthy and gets passed through. +- **Bare-mustache numeric `0` is in the falsy set for `muted`.** Verified for `muted` (`{{0}}` → omitted). Not yet tested for `disabled` / `aria-hidden` / `autocomplete`. +- **Concat-mustache forks by attribute kind.** For HTML boolean attrs (`muted`, `disabled`), any concat — including `"{{false}}"`, `"{{'false'}}"`, `"x{{false}}"` — sets the IDL property to `true`, regardless of the literal value inside. For ARIA / string attrs (`aria-hidden`, `autocomplete`), concat renders the stringified value as the attribute value (no boolean coercion); `aria-hidden="{{false}}"` becomes `aria-hidden="false"` (visible). +- **Concat is never falsy.** Across all attribute kinds tested, no concat form produces an absent attribute. Rules treating `attr="{{false}}"` as "off" are wrong for boolean attrs (it's IDL-true) and wrong for string attrs (the rendered value is `"false"`, attribute present). + +## Reading attribute values in rules + +Rule authors who classify attribute values must consume the reference table above through one of these AST shapes — each maps to a known rendering verdict: + +| AST shape | Source examples | Verdict | +| ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ------------------------------------------------------------- | -------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| `attr.value === null` (no value) | `` | Attribute is **present** with empty value (rendered as `attr=""`) — see d1, h1 | +| `attr.value.type === 'GlimmerTextNode'` | `attr="literal text"` | Attribute is **present** with the literal `chars` string — see m1–m4, h2–h4, d1, t-static, i1 | +| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerBooleanLiteral'` and `path.value === true` | `attr={{true}}` | **Reflecting boolean attrs**: present (e.g., `disabled=""`). **Non-reflecting boolean attrs** (`muted`, `autoplay`, etc.): IDL property set true, HTML attribute omitted. **ARIA string attrs**: present as `attr=""`. **Numeric attrs**: untested. — see m5, d2, h5 | +| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerBooleanLiteral'` and `path.value === false` (or `GlimmerNullLiteral` / `GlimmerUndefinedLiteral`) | `attr={{false}}` / `{{null}}` / `{{undefined}}` | Attribute is **omitted** at runtime — see m6, m9, m10, d3, d6, h6, h9, h10, t6, t7. **Exception:** plain string attrs (e.g., `autocomplete`) do _not_ falsy-coerce; bare `{{false}}` renders as `attr="false"` (i4). | +| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerStringLiteral'` | `attr={{"value"}}` | Attribute is **present** with the literal `path.value` string — see m7, m8, h7, h8, d4, d5, i2 | +| `attr.value.type === 'GlimmerMustacheStatement'` with `path.type === 'GlimmerNumberLiteral'` (verified for `tabindex` only) | `attr={{0}}` | **Numeric attrs**: present with stringified number (t1, t2, t3). **`muted={{0}}` is in the falsy-omit set** (m12); not yet tested for other kinds. | +| `attr.value.type === 'GlimmerMustacheStatement'` with dynamic path | `attr={{this.x}}` | **Unknown** at lint time. | +| `attr.value.type === 'GlimmerConcatStatement'` with all-literal parts | `attr="{{X}}"` (single literal-mustache) / `attr="text{{X}}"` | **Boolean HTML attrs** (`muted`, `disabled`, etc.): IDL true regardless of inner literal — m13–m19, d7–d10. **ARIA / string attrs** (`aria-hidden`, `autocomplete`): renders the stringified value literally — h12–h15, i3, i5. | +| `attr.value.type === 'GlimmerConcatStatement'` with any dynamic part | `attr="{{this.x}}"` / `attr="x{{this.y}}"` | **Concat is never falsy** — present at runtime, but the value is generally **unknown** at lint time. | + +### Common mistakes to avoid + +1. **Don't use `findAttr(node, 'foo')` (AST-presence) as a proxy for "attribute set at runtime."** It's wrong for bare `{{false}}` / `{{null}}` / `{{undefined}}` on boolean-coerced attrs (m6/m9/m10/d3/d6/h6/h9/h10/t6/t7) — those forms still create an `AttrNode` in the AST but are omitted at runtime. +2. **Don't lump `BooleanLiteral(false)` with `StringLiteral("false")`.** Bare `{{"false"}}` is a JS-truthy string; it renders the literal `"false"` value (i4, h8, d4, m8). Treating them together as "off" is the most common audit footgun in this codebase. +3. **Don't treat single-mustache concat as the inner literal.** `attr="{{X}}"` is **never** falsy. For boolean HTML attrs the IDL property is set true regardless of `X`'s literal value (m14 verified: `