` inside an otherwise empty anchor should flag.
+ {
+ filename: 'test.gjs',
+ code: 'Hidden backup',
+ output: null,
+ errors: [{ messageId: 'anchorHasContent' }],
+ },
+ {
+ filename: 'test.gjs',
+ code: 'Hidden',
+ output: null,
+ errors: [{ messageId: 'anchorHasContent' }],
+ },
+ // Nested empty element.
+ {
+ filename: 'test.gjs',
+ code: '',
+ output: null,
+ errors: [{ messageId: 'anchorHasContent' }],
+ },
+ // aria-hidden={{"false"}} — string-literal mustache resolves to "false";
+ // anchor is NOT hidden, so the content check applies and should flag.
+ {
+ filename: 'test.gjs',
+ code: '',
+ output: null,
+ errors: [{ messageId: 'anchorHasContent' }],
+ },
+ // aria-hidden={{"true"}} on a child — child is hidden, contributes nothing;
+ // the anchor itself is still in scope and has no accessible content.
+ {
+ filename: 'test.gjs',
+ code: 'X',
+ output: null,
+ errors: [{ messageId: 'anchorHasContent' }],
+ },
+ ],
+});
+
+const hbsRuleTester = new RuleTester({
+ parser: require.resolve('ember-eslint-parser/hbs'),
+ parserOptions: { ecmaVersion: 2022, sourceType: 'module' },
+});
+
+hbsRuleTester.run('template-anchor-has-content (hbs)', rule, {
+ valid: [
+ // Classic-HBS mirrors of the key valid GTS cases.
+ 'link text',
+ 'inner',
+ '',
+ '',
+ '{{@label}}',
+ '{{this.label}}',
+ '
',
+ '',
+ // 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',
+ '
',
+ ],
+ invalid: [
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'anchorHasContent' }],
+ },
+ {
+ code: '',
+ output: null,
+ errors: [{ messageId: 'anchorHasContent' }],
+ },
+ {
+ code: 'X',
+ output: null,
+ errors: [{ messageId: 'anchorHasContent' }],
+ },
+ {
+ code: '
',
+ output: null,
+ errors: [{ messageId: 'anchorHasContent' }],
+ },
+ {
+ code: '
',
+ 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);