Skip to content

Commit a80c119

Browse files
wagenetclaude
andcommitted
Add rule: ember/template-no-deprecated
Flags deprecated Glimmer components, helpers, and modifiers used in <template> by walking from the template reference → ESLint scope variable → import binding → TypeScript symbol → JSDoc @deprecated tag. Requires TypeScript (parserServices.program); no-op in plain .gjs files. Enabled in recommended-gts config. Covers: - <DeprecatedComponent /> (GlimmerElementNode) - {{deprecatedHelper}} (GlimmerPathExpression) - {{#deprecatedBlock}} (GlimmerPathExpression block path) - <div {{deprecatedModifier}}> (GlimmerPathExpression modifier) @arg deprecation is not yet implemented; see docs/arg-deprecation-future-work.md. Also fixes tests/rule-setup.js to filter out subdirectories when checking that test file names match rule names. Co-Authored-By: Claude Sonnet 4.6 <[email protected]>
1 parent 0149ef1 commit a80c119

14 files changed

Lines changed: 394 additions & 4 deletions

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -291,6 +291,7 @@ rules in templates can be disabled with eslint directives with mustache or html
291291
| [no-empty-glimmer-component-classes](docs/rules/no-empty-glimmer-component-classes.md) | disallow empty backing classes for Glimmer components || | |
292292
| [no-tracked-properties-from-args](docs/rules/no-tracked-properties-from-args.md) | disallow creating @tracked properties from this.args || | |
293293
| [template-indent](docs/rules/template-indent.md) | enforce consistent indentation for gts/gjs templates | | 🔧 | |
294+
| [template-no-deprecated](docs/rules/template-no-deprecated.md) | disallow using deprecated Glimmer components, helpers, and modifiers in templates | ![gts logo](/docs/svgs/gts.svg) | | |
294295
| [template-no-let-reference](docs/rules/template-no-let-reference.md) | disallow referencing let variables in \<template\> | ![gjs logo](/docs/svgs/gjs.svg) ![gts logo](/docs/svgs/gts.svg) | | |
295296

296297
### jQuery
Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
# Future Work: `@arg` Deprecation in Templates
2+
3+
This document tracks the unimplemented case for `ember/template-no-deprecated`:
4+
detecting `@deprecated` on component arguments passed in template syntax.
5+
6+
## The Problem
7+
8+
`<MyComponent @deprecatedArg={{x}}>``@deprecatedArg` appears as a `GlimmerAttrNode`.
9+
It is never scope-registered (the parser skips args, see `ember-eslint-parser/src/parser/transforms.js`
10+
around line 504). The scope-based approach used in `template-no-deprecated` does not apply here.
11+
12+
## The Analogous Solved Problem
13+
14+
`@typescript-eslint/no-deprecated` handles JSX attributes via `getJSXAttributeDeprecation`
15+
(`no-deprecated.js:266–271`):
16+
17+
```js
18+
function getJSXAttributeDeprecation(openingElement, propertyName) {
19+
const tsNode = services.esTreeNodeToTSNodeMap.get(openingElement.name);
20+
const contextualType = checker.getContextualType(tsNode);
21+
const symbol = contextualType.getProperty(propertyName);
22+
return getJsDocDeprecation(symbol);
23+
}
24+
```
25+
26+
This works because `JSXOpeningElement.name` is a standard TS AST node, so
27+
`esTreeNodeToTSNodeMap` has it, and TypeScript's JSX support provides `getContextualType`
28+
for JSX element names (which gives the props type directly).
29+
30+
## Why This Doesn't Directly Apply to Glimmer
31+
32+
`GlimmerElementNode.parts[0]` (`GlimmerElementNodePart`) is a **synthetic node** invented by
33+
`ember-eslint-parser`. It is not in `esTreeNodeToTSNodeMap`. Therefore:
34+
35+
- `services.esTreeNodeToTSNodeMap.get(parts[0])``undefined`
36+
- `checker.getContextualType(undefined)` → crash
37+
38+
## Options
39+
40+
### Option A: Navigate the Type Manually (no Glint required)
41+
42+
1. Visit `GlimmerAttrNode` where `node.name.startsWith('@')`
43+
2. Get parent `GlimmerElementNode`, resolve its import binding (same as `template-no-deprecated`)
44+
3. `services.esTreeNodeToTSNodeMap.get(importDefNode)` → TS import node
45+
4. `checker.getTypeAtLocation(tsImportNode)` → component's constructor type
46+
5. Navigate type parameters to extract the `Args` type:
47+
- Get base type `Component<{Args: A}>` → extract first type argument → get `A`
48+
- Requires walking `type.typeArguments` and the `Args` property
49+
6. `argsType.getProperty(argName.slice(1))` → property symbol
50+
7. `getJsDocDeprecation(symbol)` → check for `@deprecated`
51+
52+
Self-contained (no Glint needed) but TypeScript type navigation is brittle —
53+
it depends on the specific generic signature of `@glimmer/component`.
54+
55+
### Option B: Register `@arg` Nodes in Scope (requires ember-eslint-parser change)
56+
57+
Modify `ember-eslint-parser/src/parser/transforms.js` to register `GlimmerAttrNode` names
58+
starting with `@` as references, using a virtual identifier that IS in `esTreeNodeToTSNodeMap`.
59+
Then the JSX-attribute pattern would work directly.
60+
61+
This is an upstream change to `ember-eslint-parser`, not this plugin.
62+
63+
### Option C: Use Glint v2 Virtual TypeScript Files (future)
64+
65+
Glint v2 (Volar) operates as a tsserver TS plugin. ESLint's `@typescript-eslint/parser`
66+
creates a separate TypeScript program that does **not** load tsserver plugins by default,
67+
so Glint's template type information is not available in `parserServices.program`.
68+
69+
To bridge this: either (1) `ember-eslint-parser` would need to integrate with Glint's
70+
virtual file infrastructure and re-expose the resulting program, or (2) Glint v2 would need
71+
to provide a TypeScript compiler transform (not just a language server plugin) that
72+
`@typescript-eslint/parser` picks up automatically. Neither exists today. File this as a
73+
Glint project concern.
74+
75+
## Recommended Path
76+
77+
Option A is the most tractable near-term approach — no upstream changes required.
78+
The main risk is brittleness against non-standard component base classes.
79+
Option B is architecturally cleaner but requires coordination with `ember-eslint-parser` maintainers.
Lines changed: 62 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,62 @@
1+
# ember/template-no-deprecated
2+
3+
💼 This rule is enabled in the ![gts logo](/docs/svgs/gts.svg) `recommended-gts` [config](https://github.com/ember-cli/eslint-plugin-ember#-configurations).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Disallows using Glimmer components, helpers, or modifiers that are marked `@deprecated` in their JSDoc.
8+
9+
This rule requires TypeScript (`parserServices.program` must be present). It is a no-op in plain `.gjs` files because cross-file import deprecations require type information.
10+
11+
## Rule Details
12+
13+
The rule resolves template references through ESLint's scope analysis: a `<Component>` or `{{helper}}` reference is traced back to its import declaration, then the TypeScript type checker inspects the exported symbol's JSDoc tags for `@deprecated`.
14+
15+
**Covered syntax:**
16+
17+
| Template syntax | Example |
18+
|---|---|
19+
| Component element | `<DeprecatedComponent />` |
20+
| Helper / value mustache | `{{deprecatedHelper}}` |
21+
| Block component | `{{#DeprecatedBlock}}…{{/DeprecatedBlock}}` |
22+
| Modifier | `<div {{deprecatedModifier}}>` |
23+
24+
**Not covered (see future work):** `<MyComp @deprecatedArg={{x}}>` — argument names are not scope-registered by the parser.
25+
26+
## Examples
27+
28+
Given a module:
29+
30+
```ts
31+
// deprecated-component.ts
32+
/** @deprecated use NewComponent instead */
33+
export default class DeprecatedComponent {}
34+
```
35+
36+
Examples of **incorrect** code for this rule:
37+
38+
```gts
39+
import DeprecatedComponent from './deprecated-component';
40+
41+
<template>
42+
<DeprecatedComponent />
43+
</template>
44+
```
45+
46+
```gts
47+
import { deprecatedHelper } from './deprecated-helper';
48+
49+
<template>
50+
{{deprecatedHelper}}
51+
</template>
52+
```
53+
54+
Examples of **correct** code for this rule:
55+
56+
```gts
57+
import NewComponent from './new-component';
58+
59+
<template>
60+
<NewComponent />
61+
</template>
62+
```

lib/recommended-rules-gjs.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,5 @@
55
* definitions, execute "npm run update"
66
*/
77
module.exports = {
8-
'ember/template-no-let-reference': 'error',
8+
"ember/template-no-let-reference": "error"
99
};

lib/recommended-rules-gts.js

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,5 +5,6 @@
55
* definitions, execute "npm run update"
66
*/
77
module.exports = {
8-
'ember/template-no-let-reference': 'error',
8+
"ember/template-no-deprecated": "error",
9+
"ember/template-no-let-reference": "error"
910
};

lib/recommended-rules.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,4 @@ module.exports = {
7575
"ember/routes-segments-snake-case": "error",
7676
"ember/use-brace-expansion": "error",
7777
"ember/use-ember-data-rfc-395-imports": "error"
78-
}
78+
};
Lines changed: 126 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,126 @@
1+
'use strict';
2+
3+
const tsutils = require('ts-api-utils');
4+
const ts = require('typescript');
5+
6+
/** @type {import('eslint').Rule.RuleModule} */
7+
module.exports = {
8+
meta: {
9+
type: 'problem',
10+
docs: {
11+
description:
12+
'disallow using deprecated Glimmer components, helpers, and modifiers in templates',
13+
category: 'Ember Octane',
14+
recommendedGts: true,
15+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-deprecated.md',
16+
},
17+
requiresTypeChecking: true,
18+
schema: [],
19+
messages: {
20+
deprecated: '`{{name}}` is deprecated.',
21+
deprecatedWithReason: '`{{name}}` is deprecated. {{reason}}',
22+
},
23+
},
24+
25+
create(context) {
26+
const services = context.sourceCode.parserServices ?? context.parserServices;
27+
if (!services?.program) {
28+
return {};
29+
}
30+
31+
const checker = services.program.getTypeChecker();
32+
const sourceCode = context.sourceCode;
33+
34+
function getJsDocDeprecation(symbol) {
35+
let jsDocTags;
36+
try {
37+
jsDocTags = symbol?.getJsDocTags(checker);
38+
} catch {
39+
// workaround for https://github.com/microsoft/TypeScript/issues/60024
40+
return undefined;
41+
}
42+
const tag = jsDocTags?.find((t) => t.name === 'deprecated');
43+
if (!tag) {
44+
return undefined;
45+
}
46+
const displayParts = tag.text;
47+
return displayParts ? ts.displayPartsToString(displayParts) : '';
48+
}
49+
50+
function searchForDeprecationInAliasesChain(symbol, checkAliasedSymbol) {
51+
if (!symbol || !tsutils.isSymbolFlagSet(symbol, ts.SymbolFlags.Alias)) {
52+
return checkAliasedSymbol ? getJsDocDeprecation(symbol) : undefined;
53+
}
54+
const targetSymbol = checker.getAliasedSymbol(symbol);
55+
while (tsutils.isSymbolFlagSet(symbol, ts.SymbolFlags.Alias)) {
56+
const reason = getJsDocDeprecation(symbol);
57+
if (reason != null) {
58+
return reason;
59+
}
60+
const immediateAliasedSymbol =
61+
symbol.getDeclarations() && checker.getImmediateAliasedSymbol(symbol);
62+
if (!immediateAliasedSymbol) {
63+
break;
64+
}
65+
symbol = immediateAliasedSymbol;
66+
if (checkAliasedSymbol && symbol === targetSymbol) {
67+
return getJsDocDeprecation(symbol);
68+
}
69+
}
70+
return undefined;
71+
}
72+
73+
function checkDeprecatedIdentifier(identifierNode, scope) {
74+
const ref = scope.references.find((v) => v.identifier === identifierNode);
75+
const variable = ref?.resolved;
76+
const def = variable?.defs[0];
77+
78+
if (!def || def.type !== 'ImportBinding') {
79+
return;
80+
}
81+
82+
const tsNode = services.esTreeNodeToTSNodeMap.get(def.node);
83+
if (!tsNode) {
84+
return;
85+
}
86+
87+
// ImportClause and ImportSpecifier require .name for getSymbolAtLocation
88+
const tsIdentifier = tsNode.name ?? tsNode;
89+
const symbol = checker.getSymbolAtLocation(tsIdentifier);
90+
if (!symbol) {
91+
return;
92+
}
93+
94+
const reason = searchForDeprecationInAliasesChain(symbol, true);
95+
if (reason == null) {
96+
return;
97+
}
98+
99+
if (reason === '') {
100+
context.report({
101+
node: identifierNode,
102+
messageId: 'deprecated',
103+
data: { name: identifierNode.name },
104+
});
105+
} else {
106+
context.report({
107+
node: identifierNode,
108+
messageId: 'deprecatedWithReason',
109+
data: { name: identifierNode.name, reason },
110+
});
111+
}
112+
}
113+
114+
return {
115+
GlimmerPathExpression(node) {
116+
checkDeprecatedIdentifier(node.head, sourceCode.getScope(node));
117+
},
118+
119+
GlimmerElementNode(node) {
120+
// GlimmerElementNode is in its own scope; get the outer scope
121+
const scope = sourceCode.getScope(node.parent);
122+
checkDeprecatedIdentifier(node.parts[0], scope);
123+
},
124+
};
125+
},
126+
};

tests/__snapshots__/recommended.js.snap

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,7 @@ exports[`recommended rules > gjs config has the right list 1`] = `
88

99
exports[`recommended rules > gts config has the right list 1`] = `
1010
[
11+
"template-no-deprecated",
1112
"template-no-let-reference",
1213
]
1314
`;
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export default class CurrentComponent {}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
/** @deprecated use NewComponent instead */
2+
export default class DeprecatedComponent {}

0 commit comments

Comments
 (0)