From 0172580c77c8cb4e8a8e3d52cdec13bb74832968 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Johan=20R=C3=B8ed?= Date: Mon, 27 Apr 2026 21:28:50 +0200 Subject: [PATCH] feat: add template-anchor-has-content --- README.md | 1 + docs/rules/template-anchor-has-content.md | 58 +++ lib/rules/template-anchor-has-content.js | 241 +++++++++++++ lib/utils/is-native-element.js | 16 +- .../lib/rules/template-anchor-has-content.js | 341 ++++++++++++++++++ tests/lib/utils/is-native-element-test.js | 81 ++++- 6 files changed, 722 insertions(+), 16 deletions(-) create mode 100644 docs/rules/template-anchor-has-content.md create mode 100644 lib/rules/template-anchor-has-content.js create mode 100644 tests/lib/rules/template-anchor-has-content.js diff --git a/README.md b/README.md index 406ed89301..161c68f638 100644 --- a/README.md +++ b/README.md @@ -258,6 +258,7 @@ To disable a rule for an entire `.gjs`/`.gts` file, use a regular ESLint file-le | Name                                            | Description | 💼 | 🔧 | 💡 | | :--------------------------------------------------------------------------------------------------------------- | :------------------------------------------------------------------------------- | :- | :- | :- | +| [template-anchor-has-content](docs/rules/template-anchor-has-content.md) | require anchor elements to contain accessible content | | | | | [template-link-href-attributes](docs/rules/template-link-href-attributes.md) | require href attribute on link elements | 📋 | | | | [template-no-abstract-roles](docs/rules/template-no-abstract-roles.md) | disallow abstract ARIA roles | 📋 | | | | [template-no-accesskey-attribute](docs/rules/template-no-accesskey-attribute.md) | disallow accesskey attribute | 📋 | 🔧 | | diff --git a/docs/rules/template-anchor-has-content.md b/docs/rules/template-anchor-has-content.md new file mode 100644 index 0000000000..d8513ce913 --- /dev/null +++ b/docs/rules/template-anchor-has-content.md @@ -0,0 +1,58 @@ +# ember/template-anchor-has-content + + + +Requires every `` anchor to expose a non-empty accessible name to assistive technology. + +An anchor with no text, no accessible-name attribute (`aria-label`, `aria-labelledby`, `title`), and no accessible children (e.g. an `` with non-empty `alt`) is rendered by screen readers as an empty link. Users have no way to tell what the link does. + +## Rule Details + +The rule only inspects plain `` elements that have an `href` attribute. Component invocations (PascalCase, `@arg`, `this.foo`, `foo.bar`, `foo::bar`) are skipped — the rule cannot see through a component's implementation. + +For each in-scope anchor the rule computes whether the element exposes an accessible name: + +- A non-empty `aria-label`, `aria-labelledby`, or `title` on the anchor itself is an accessible name (any non-static / dynamic value is trusted). +- Static text (including text nested inside child elements) is an accessible name. +- `...` children contribute their `alt` to the name. +- Children with `aria-hidden="true"` (or `{{true}}`) contribute nothing, even if they contain text or `alt`. Valueless / empty-string `aria-hidden` resolves to the default `undefined` per the WAI-ARIA value table and is treated as not-hidden — those children still contribute. +- Dynamic content (`{{@foo}}`, `{{this.foo}}`, `{{#if ...}}`) is treated as opaque: the rule does not flag the anchor because it cannot know what will render. + +## Examples + +This rule **allows** the following: + +```gjs + +``` + +This rule **forbids** the following: + +```gjs + +``` + +## References + +- [W3C: Accessible Name and Description Computation (accname)](https://www.w3.org/TR/accname/) +- [MDN: The Anchor element](https://developer.mozilla.org/en-US/docs/Web/HTML/Element/a) +- [WCAG 2.4.4 — Link Purpose (In Context)](https://www.w3.org/WAI/WCAG21/Understanding/link-purpose-in-context.html) +- [jsx-a11y/anchor-has-content](https://github.com/jsx-eslint/eslint-plugin-jsx-a11y/blob/main/docs/rules/anchor-has-content.md) +- [vuejs-accessibility/anchor-has-content](https://github.com/vue-a11y/eslint-plugin-vuejs-accessibility/blob/main/docs/rules/anchor-has-content.md) diff --git a/lib/rules/template-anchor-has-content.js b/lib/rules/template-anchor-has-content.js new file mode 100644 index 0000000000..1d8882f5ed --- /dev/null +++ b/lib/rules/template-anchor-has-content.js @@ -0,0 +1,241 @@ +'use strict'; + +const { isNativeElement } = require('../utils/is-native-element'); +const { getStaticAttrValue } = require('../utils/static-attr-value'); + +function isDynamicValue(value) { + return value?.type === 'GlimmerMustacheStatement' || value?.type === 'GlimmerConcatStatement'; +} + +// Returns true if the `aria-hidden` attribute is explicitly set to "true" +// (case-insensitive) or mustache-literal `{{true}}` / `{{"true"}}` / the +// quoted-mustache concat equivalents. Per WAI-ARIA 1.2 §6.6 + aria-hidden +// value table, valueless / empty-string `aria-hidden` resolves to the +// default `undefined` — NOT `true` — so those forms do NOT hide the +// element per spec. This aligns with the spec-first decisions in #2717 / +// #19 / #33, and diverges from jsx-a11y's JSX-coercion convention. All +// shape-unwrapping is delegated to the shared `getStaticAttrValue` helper. +function isAriaHiddenTrue(attr) { + if (!attr) { + return false; + } + const resolved = getStaticAttrValue(attr.value); + if (resolved === undefined) { + // Dynamic — can't prove truthy. + return false; + } + return resolved.trim().toLowerCase() === 'true'; +} + +// True if the anchor itself declares an accessible name via a statically +// non-empty `aria-label`, `aria-labelledby`, or `title`, OR via a dynamic +// value (we can't know at lint time whether a mustache resolves to an empty +// string, so we give the author the benefit of the doubt — matching the +// "skip dynamic" posture used by `template-no-invalid-link-text`). +function hasAccessibleNameAttribute(node) { + const attrs = node.attributes || []; + for (const name of ['aria-label', 'aria-labelledby', 'title']) { + const attr = attrs.find((a) => a.name === name); + if (!attr) { + continue; + } + if (attr.value?.type === 'GlimmerMustacheStatement') { + const resolved = getStaticAttrValue(attr.value); + if (resolved === undefined) { + // Truly dynamic (e.g. `aria-label={{@label}}`) — can't know at lint + // time; give the author the benefit of the doubt. + return true; + } + // Static string literal in mustache, e.g. `aria-label={{""}}`. + // Treat exactly like a plain text value: non-empty means a name exists. + if (resolved.trim().length > 0) { + return true; + } + continue; + } + if (isDynamicValue(attr.value)) { + // GlimmerConcatStatement — treat as dynamic. + return true; + } + if (attr.value?.type === 'GlimmerTextNode') { + // Normalize ` ` to space before the whitespace check — matches the + // sibling rule `template-no-invalid-link-text`. `aria-label=" "` + // is functionally empty for assistive tech (no visual content, no + // announced text) and shouldn't count as an accessible name. + const chars = attr.value.chars.replaceAll(' ', ' '); + if (chars.trim().length > 0) { + return true; + } + } + } + return false; +} + +// Recursively inspect a single child node and report how it would contribute +// to the anchor's accessible name. +// { dynamic: true } — opaque at lint time; treat anchor as labeled. +// { accessible: true } — statically contributes a non-empty name. +// { accessible: false } — contributes nothing (empty text, aria-hidden +// subtree, without non-empty alt, …). +function evaluateChild(child, sourceCode) { + if (child.type === 'GlimmerTextNode') { + const text = child.chars.replaceAll(' ', ' ').trim(); + return { dynamic: false, accessible: text.length > 0 }; + } + + if ( + child.type === 'GlimmerMustacheStatement' || + child.type === 'GlimmerSubExpression' || + child.type === 'GlimmerBlockStatement' + ) { + // Dynamic content — can't statically tell whether it renders to something. + // Mirror `template-no-invalid-link-text`'s stance and skip. + return { dynamic: true, accessible: false }; + } + + if (child.type === 'GlimmerElementNode') { + const attrs = child.attributes || []; + const ariaHidden = attrs.find((a) => a.name === 'aria-hidden'); + if (isAriaHiddenTrue(ariaHidden)) { + // aria-hidden subtrees contribute nothing, regardless of content. + return { dynamic: false, accessible: false }; + } + + // HTML boolean `hidden` (§5.4) removes the element from rendering AND + // from the accessibility tree — equivalent to aria-hidden="true" for + // accessible-name purposes. A inside an + // anchor contributes no name at runtime. + if (attrs.some((a) => a.name === 'hidden')) { + return { dynamic: false, accessible: false }; + } + + // Non-native children (components, custom elements, scope-shadowed tags) + // are opaque — we can't see inside them. + if (!isNativeElement(child, sourceCode)) { + return { dynamic: true, accessible: false }; + } + + // An child contributes its alt text to the anchor's accessible name. + if (child.tag?.toLowerCase() === 'img') { + const altAttr = attrs.find((a) => a.name === 'alt'); + if (!altAttr) { + // Missing alt is a separate a11y concern; treat as no contribution. + return { dynamic: false, accessible: false }; + } + if (altAttr.value?.type === 'GlimmerMustacheStatement') { + const resolved = getStaticAttrValue(altAttr.value); + if (resolved === undefined) { + // Truly dynamic (e.g. `alt={{@alt}}`) — trust the author. + return { dynamic: true, accessible: false }; + } + // Static string literal in mustache, e.g. `alt={{""}}` or + // `alt={{"Search"}}` — treat exactly like a plain text value. + return { dynamic: false, accessible: resolved.trim().length > 0 }; + } + if (isDynamicValue(altAttr.value)) { + // GlimmerConcatStatement — treat as dynamic. + return { dynamic: true, accessible: false }; + } + if (altAttr.value?.type === 'GlimmerTextNode') { + // Same ` ` normalization as hasAccessibleNameAttribute above — + // ` ` contributes no meaningful name. + const chars = altAttr.value.chars.replaceAll(' ', ' '); + return { dynamic: false, accessible: chars.trim().length > 0 }; + } + return { dynamic: false, accessible: false }; + } + + // For any other HTML element child, recurse into its children AND its own + // aria-label/aria-labelledby/title (author may label an inner ). + if (hasAccessibleNameAttribute(child)) { + return { dynamic: false, accessible: true }; + } + + return evaluateChildren(child.children || [], sourceCode); + } + + return { dynamic: false, accessible: false }; +} + +function evaluateChildren(children, sourceCode) { + let dynamic = false; + for (const child of children) { + const result = evaluateChild(child, sourceCode); + if (result.accessible) { + return { dynamic: false, accessible: true }; + } + if (result.dynamic) { + dynamic = true; + } + } + return { dynamic, accessible: false }; +} + +/** @type {import('eslint').Rule.RuleModule} */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'require anchor elements to contain accessible content', + category: 'Accessibility', + url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-anchor-has-content.md', + templateMode: 'both', + }, + fixable: null, + schema: [], + messages: { + anchorHasContent: + 'Anchors must have content and the content must be accessible by a screen reader.', + }, + }, + + create(context) { + const sourceCode = context.sourceCode || context.getSourceCode(); + return { + GlimmerElementNode(node) { + // Only the native element — in strict GJS, a lowercase tag can be + // shadowed by an in-scope local binding, and components shouldn't be + // validated here. `isNativeElement` combines authoritative html/svg/ + // mathml tag lists with scope-shadowing detection. + if (!isNativeElement(node, sourceCode)) { + return; + } + if (node.tag?.toLowerCase() !== 'a') { + return; + } + + // Only anchors acting as links (with href) are in scope. An without + // href is covered by `template-link-href-attributes` / not a link. + const attrs = node.attributes || []; + const hasHref = attrs.some((a) => a.name === 'href'); + if (!hasHref) { + return; + } + + // Skip anchors the author has explicitly hidden — either via the HTML + // `hidden` boolean attribute (element is not rendered at all) or + // `aria-hidden="true"` (element removed from the accessibility tree). + // In both cases, "accessible name of an anchor" is moot. + const hasHidden = attrs.some((a) => a.name === 'hidden'); + if (hasHidden) { + return; + } + const ariaHiddenAttr = attrs.find((a) => a.name === 'aria-hidden'); + if (isAriaHiddenTrue(ariaHiddenAttr)) { + return; + } + + if (hasAccessibleNameAttribute(node)) { + return; + } + + const result = evaluateChildren(node.children || [], sourceCode); + if (result.accessible || result.dynamic) { + return; + } + + context.report({ node, messageId: 'anchorHasContent' }); + }, + }; + }, +}; diff --git a/lib/utils/is-native-element.js b/lib/utils/is-native-element.js index 190374d9bb..ebdad3b9c4 100644 --- a/lib/utils/is-native-element.js +++ b/lib/utils/is-native-element.js @@ -21,19 +21,17 @@ const ELEMENT_TAGS = new Set([...htmlTags, ...svgTags, ...mathmlTagNames]); * MathML spec registries, reached via the `html-tags` / `svg-tags` / * `mathml-tag-names` packages). It is NOT the same as: * - * - "native accessibility" / "widget-ness" — see `interactive-roles.js` - * (aria-query widget taxonomy; an ARIA-tree-semantics question) - * - "native interactive content" / "focus behavior" — see - * `html-interactive-content.js` (HTML §3.2.5.2.7; an HTML-content-model - * question about which tags can be nested inside what) + * - "native accessibility" / "widget-ness" — an ARIA-tree-semantics + * question (for example, whether something maps to a widget role) + * - "native interactive content" / "focus behavior" — an HTML content-model + * question about which elements are considered interactive in the spec * - "natively focusable" / sequential-focus — see HTML §6.6.3 * * This util answers only: "is this tag a first-class built-in element of one * of the three markup-language standards, rather than a component invocation - * or a shadowed local binding?" Callers compose it with the other utils - * above when they need a more specific question (see e.g. `template-no- - * noninteractive-tabindex`, which consults both this and - * `html-interactive-content`). + * or a shadowed local binding?" Callers should combine it with whatever + * accessibility, interactivity, or focusability checks they need for more + * specific questions. * * Returns false for: * - components (PascalCase, dotted, @-prefixed, this.-prefixed, ::-namespaced — diff --git a/tests/lib/rules/template-anchor-has-content.js b/tests/lib/rules/template-anchor-has-content.js new file mode 100644 index 0000000000..96b7bd91c5 --- /dev/null +++ b/tests/lib/rules/template-anchor-has-content.js @@ -0,0 +1,341 @@ +'use strict'; + +const rule = require('../../../lib/rules/template-anchor-has-content'); +const RuleTester = require('eslint').RuleTester; + +const ruleTester = new RuleTester({ + parser: require.resolve('ember-eslint-parser'), + parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, +}); + +ruleTester.run('template-anchor-has-content', rule, { + valid: [ + // Text content — the baseline accessible name. + { filename: 'test.gjs', code: '' }, + + // Nested element with static text — the span's text surfaces as the name. + { filename: 'test.gjs', code: '' }, + + // Explicit accessible-name attributes on the anchor itself. + { filename: 'test.gjs', code: '' }, + { filename: 'test.gjs', code: '' }, + { filename: 'test.gjs', code: '' }, + + // Dynamic accessible-name attribute — opaque, trust the author. + { + filename: 'test.gjs', + code: '', + }, + + // Dynamic content — opaque at lint time, skip. + { filename: 'test.gjs', code: '' }, + { filename: 'test.gjs', code: '' }, + { filename: 'test.gjs', code: '' }, + { + filename: 'test.gjs', + code: '', + }, + + // … contributes its alt to the accessible name. + { + filename: 'test.gjs', + code: '', + }, + + // {{…}} — dynamic alt, trust the author. + { + filename: 'test.gjs', + code: '', + }, + + // {{"…"}} — static string in mustache; non-empty alt counts as + // accessible content. + { + filename: 'test.gjs', + code: '', + }, + + // aria-label with a non-empty mustache string literal is a valid name. + { + filename: 'test.gjs', + code: '', + }, + + // Component invocation (PascalCase) — not a plain HTML anchor, out of scope. + { filename: 'test.gjs', code: '' }, + + // Nested component child inside a plain — opaque, skip. + { + filename: 'test.gjs', + code: '', + }, + + // Anchor without href — out of scope (handled by template-link-href-attributes). + { filename: 'test.gjs', code: '' }, + { filename: 'test.gjs', code: '' }, + + // Label on a nested element (via aria-label on the child). + { + filename: 'test.gjs', + code: '', + }, + + // Valueless / empty aria-hidden resolves to default `undefined` per + // WAI-ARIA 1.2 §6.6 — the child is NOT hidden, its content counts. + { + filename: 'test.gjs', + code: '', + }, + { + filename: 'test.gjs', + code: '', + }, + { + filename: 'test.gjs', + code: '', + }, + + // Anchor itself hidden via HTML `hidden` boolean attribute — element is + // not rendered, so "accessible name of an anchor" is moot. + { + filename: 'test.gjs', + code: '', + }, + { + filename: 'test.gjs', + code: '', + }, + + // Anchor itself hidden via aria-hidden="true" — removed from the a11y + // tree, so the accessible-name check does not apply. + { + filename: 'test.gjs', + code: '', + }, + { + filename: 'test.gjs', + code: '', + }, + // aria-hidden={{"true"}} — string-literal mustache, resolved statically to + // "true"; anchor is hidden from the a11y tree, check does not apply. + { + filename: 'test.gjs', + code: '', + }, + + // Scope-shadowed lowercase `a` (local binding in GJS) — not the native + // HTML anchor, so the rule does not validate it. `isNativeElement` + // detects the shadowing via scope bindings in the scope chain. + { + filename: 'test.gjs', + code: ` + const a = ''; + + `, + }, + ], + + invalid: [ + // Self-closing anchor with href — no content, no accessible name. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // Empty anchor. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // Whitespace-only content. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // aria-hidden="true" subtree contributes nothing to the accessible name. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // — alt not exposed when hidden. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // with no alt at all — nothing to surface. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // — empty alt is explicit "no accessible name". + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // Empty aria-label / aria-labelledby / title are NOT names. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // aria-label={{""}} — static empty string in mustache is NOT a name. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // {{""}} — static empty string in mustache is decorative; + // no accessible name contributed. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // ` ` is normalized to space — `aria-label=" "` is functionally + // empty for assistive tech and should not count as an accessible name. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // Same normalization in the `` contribution path. + { + filename: 'test.gjs', + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + // Children hidden via HTML `hidden` boolean attribute are not rendered + // and not exposed to AT (HTML §5.4) — they contribute no accessible + // name. A `link text', + 'inner', + '', + '', + '{{@label}}', + '{{this.label}}', + 'Search', + '', + // Anchors without href are out of scope. + '', + 'Foo', + // Valueless aria-hidden resolves to default `undefined` per ARIA §6.6 — + // child is not hidden, its content counts. + 'X', + 'Nope', + ], + invalid: [ + { + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + { + code: 'foo', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + { + code: '', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + { + code: ' ', + output: null, + errors: [{ messageId: 'anchorHasContent' }], + }, + ], +}); diff --git a/tests/lib/utils/is-native-element-test.js b/tests/lib/utils/is-native-element-test.js index ddf12916c2..ef00dd37aa 100644 --- a/tests/lib/utils/is-native-element-test.js +++ b/tests/lib/utils/is-native-element-test.js @@ -2,10 +2,24 @@ const { isNativeElement, ELEMENT_TAGS } = require('../../../lib/utils/is-native-element'); -// Tests exercise the list-lookup path only. Scope-based shadowing is covered -// by the rule-level test suites (see tests/lib/rules/template-no-block-params- -// for-html-elements.js and siblings) because it requires a real ESLint -// SourceCode / scope manager that's only built up by the rule tester. +// Tests cover both the list-lookup path (no sourceCode) and scope-shadowing +// detection via lightweight sourceCode stubs. Rule-level suites exercise +// shadowing against a real ESLint scope manager built by ember-eslint-parser +// (see tests/lib/rules/template-no-block-params-for-html-elements.js and +// siblings); these unit tests verify the shadowing branch in isolation with +// a minimal stub of the `getScope`/`variables`/`upper` surface it touches. + +// Stub a minimal ESLint-shaped sourceCode object. The real one uses scope +// managers produced by ember-eslint-parser; for unit-level coverage we mock +// just the surface `isNativeElement` touches: `getScope(parent)` returning +// an object with `variables` (bindings) and `upper` (parent scope). +function stubSourceCode(scopeByParent) { + return { + getScope(parent) { + return scopeByParent.get(parent) || { variables: [], upper: null }; + }, + }; +} describe('isNativeElement — list-only behavior (no sourceCode)', () => { it('returns true for lowercase HTML tag names', () => { @@ -79,11 +93,64 @@ describe('isNativeElement — list-only behavior (no sourceCode)', () => { }); }); +describe('isNativeElement — scope-shadowing (with sourceCode stubs)', () => { + // Rule-level integration tests (tests/lib/rules/...) cover the real + // parser's shape; here we mock the minimal surface `isNativeElement` + // touches via `stubSourceCode` above. + + it('treats a tag as shadowed when its name matches an actual binding', () => { + const parent = { type: 'Template' }; + const node = { tag: 'div', parent, parts: [{ name: 'div' }] }; + const scope = { variables: [{ name: 'div' }], upper: null }; + const sourceCode = stubSourceCode(new Map([[parent, scope]])); + expect(isNativeElement(node, sourceCode)).toBe(false); + }); + + it('walks up the scope chain for outer-scope bindings', () => { + const parent = { type: 'Template' }; + const outer = { variables: [{ name: 'div' }], upper: null }; + const inner = { variables: [], upper: outer }; + const node = { tag: 'div', parent, parts: [{ name: 'div' }] }; + const sourceCode = stubSourceCode(new Map([[parent, inner]])); + expect(isNativeElement(node, sourceCode)).toBe(false); + }); + + it('does NOT treat a tag as shadowed when the matching name is only a reference (e.g. `{{div}}` helper call)', () => { + // Regression for the class of false positive Copilot flagged: a mustache + // helper invocation like `{{div}}` populates `scope.references` with a + // `div` entry but does not create a binding. The tag `
` must still + // be treated as native HTML. + const parent = { type: 'Template' }; + const node = { tag: 'div', parent, parts: [{ name: 'div' }] }; + const scope = { + variables: [], + references: [{ identifier: { name: 'div' } }], // helper-call reference + upper: null, + }; + const sourceCode = stubSourceCode(new Map([[parent, scope]])); + expect(isNativeElement(node, sourceCode)).toBe(true); + }); + + it('skips the scope check when sourceCode is not provided (list-only fallback)', () => { + const node = { tag: 'div', parent: { type: 'Template' }, parts: [{ name: 'div' }] }; + expect(isNativeElement(node)).toBe(true); + }); + + it('skips the scope check when the node has no parent (detached)', () => { + const node = { tag: 'div', parent: null, parts: [{ name: 'div' }] }; + const sourceCode = stubSourceCode(new Map()); + expect(isNativeElement(node, sourceCode)).toBe(true); + }); +}); + describe('ELEMENT_TAGS', () => { it('includes all HTML, SVG, and MathML tag names', () => { - // Sanity check — if this ever drops below a reasonable size, one of the - // underlying packages has changed contract. - expect(ELEMENT_TAGS.size).toBeGreaterThan(200); + // Contract check — the set must be non-empty and must contain at least + // one representative tag from each of the three source packages. An exact + // size assertion would be brittle (the underlying packages add/remove tags + // across minor releases without changing their contract), so we assert the + // shape instead. + expect(ELEMENT_TAGS.size).toBeGreaterThan(0); expect(ELEMENT_TAGS.has('div')).toBe(true); expect(ELEMENT_TAGS.has('circle')).toBe(true); expect(ELEMENT_TAGS.has('mfrac')).toBe(true);