Skip to content

Commit 4629cbb

Browse files
Merge pull request #2448 from wagenet/pwn/template-no-deprecated
Add rule: ember/template-no-deprecated
2 parents 3b7ed56 + 4ee1869 commit 4629cbb

File tree

10 files changed

+436
-0
lines changed

10 files changed

+436
-0
lines changed

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -294,6 +294,7 @@ rules in templates can be disabled with eslint directives with mustache or html
294294
| [no-empty-glimmer-component-classes](docs/rules/no-empty-glimmer-component-classes.md) | disallow empty backing classes for Glimmer components || | |
295295
| [no-tracked-properties-from-args](docs/rules/no-tracked-properties-from-args.md) | disallow creating @tracked properties from this.args || | |
296296
| [template-indent](docs/rules/template-indent.md) | enforce consistent indentation for gts/gjs templates | | 🔧 | |
297+
| [template-no-deprecated](docs/rules/template-no-deprecated.md) | disallow using deprecated Glimmer components, helpers, and modifiers in templates | | | |
297298
| [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) | | |
298299

299300
### jQuery
Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# ember/template-no-deprecated
2+
3+
<!-- end auto-generated rule header -->
4+
5+
Disallows using components, helpers, or modifiers that are marked `@deprecated` in their JSDoc.
6+
7+
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.
8+
9+
## Rule Details
10+
11+
This rule checks if imported Glimmer components, helpers, or modifiers are marked `@deprecated` in their JSDoc.
12+
13+
**Covered syntax:**
14+
15+
| Template syntax | Example |
16+
| ----------------------- | ------------------------------------------- |
17+
| Component element | `<DeprecatedComponent />` |
18+
| Helper / value mustache | `{{deprecatedHelper}}` |
19+
| Block component | `{{#DeprecatedBlock}}…{{/DeprecatedBlock}}` |
20+
| Modifier | `<div {{deprecatedModifier}}>` |
21+
| Component argument | `<MyComp @deprecatedArg={{x}}>` |
22+
23+
## Examples
24+
25+
Given a module:
26+
27+
```ts
28+
// deprecated-component.ts
29+
/** @deprecated use NewComponent instead */
30+
export default class DeprecatedComponent {}
31+
```
32+
33+
Examples of **incorrect** code for this rule:
34+
35+
```gts
36+
import DeprecatedComponent from './deprecated-component';
37+
38+
<template>
39+
<DeprecatedComponent />
40+
</template>
41+
```
42+
43+
```gts
44+
import { deprecatedHelper } from './deprecated-helper';
45+
46+
<template>
47+
{{deprecatedHelper}}
48+
</template>
49+
```
50+
51+
Examples of **correct** code for this rule:
52+
53+
```gts
54+
import NewComponent from './new-component';
55+
56+
<template>
57+
<NewComponent />
58+
</template>
59+
```
Lines changed: 217 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,217 @@
1+
'use strict';
2+
3+
// ts.SymbolFlags.Alias = 2097152 (1 << 21).
4+
// Hardcoded to avoid adding a direct `typescript` dependency. This value has
5+
// been stable since TypeScript was open-sourced (~2014) but is not formally
6+
// guaranteed. If it ever changes, this rule will need to require the user's
7+
// installed TypeScript and read ts.SymbolFlags.Alias at runtime.
8+
const TS_ALIAS_FLAG = 2_097_152;
9+
10+
/** @type {import('eslint').Rule.RuleModule} */
11+
module.exports = {
12+
meta: {
13+
type: 'problem',
14+
docs: {
15+
description:
16+
'disallow using deprecated Glimmer components, helpers, and modifiers in templates',
17+
category: 'Ember Octane',
18+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-deprecated.md',
19+
},
20+
schema: [],
21+
messages: {
22+
deprecated: '`{{name}}` is deprecated.',
23+
deprecatedWithReason: '`{{name}}` is deprecated. {{reason}}',
24+
},
25+
},
26+
27+
create(context) {
28+
const services = context.sourceCode.parserServices ?? context.parserServices;
29+
if (!services?.program) {
30+
return {};
31+
}
32+
33+
const checker = services.program.getTypeChecker();
34+
const sourceCode = context.sourceCode;
35+
36+
// Cache component class symbol → Args object type (null = no Args) per lint run.
37+
const argsTypeCache = new Map();
38+
39+
function getComponentArgsType(classSymbol) {
40+
if (argsTypeCache.has(classSymbol)) {
41+
return argsTypeCache.get(classSymbol);
42+
}
43+
let result = null;
44+
try {
45+
const declaredType = checker.getDeclaredTypeOfSymbol(classSymbol);
46+
const baseTypes = checker.getBaseTypes(declaredType);
47+
outer: for (const base of baseTypes) {
48+
for (const arg of checker.getTypeArguments(base) ?? []) {
49+
const argsSymbol = arg.getProperty('Args');
50+
if (argsSymbol) {
51+
result = checker.getTypeOfSymbol(argsSymbol);
52+
break outer;
53+
}
54+
}
55+
}
56+
} catch {
57+
result = null;
58+
}
59+
argsTypeCache.set(classSymbol, result);
60+
return result;
61+
}
62+
63+
function getJsDocDeprecation(symbol) {
64+
let jsDocTags;
65+
try {
66+
jsDocTags = symbol?.getJsDocTags(checker);
67+
} catch {
68+
// workaround for https://github.com/microsoft/TypeScript/issues/60024
69+
return undefined;
70+
}
71+
const tag = jsDocTags?.find((t) => t.name === 'deprecated');
72+
if (!tag) {
73+
return undefined;
74+
}
75+
const displayParts = tag.text;
76+
return displayParts ? displayParts.map((p) => p.text).join('') : '';
77+
}
78+
79+
function searchForDeprecationInAliasesChain(symbol, checkAliasedSymbol) {
80+
// eslint-disable-next-line no-bitwise
81+
if (!symbol || !(symbol.flags & TS_ALIAS_FLAG)) {
82+
return checkAliasedSymbol ? getJsDocDeprecation(symbol) : undefined;
83+
}
84+
const targetSymbol = checker.getAliasedSymbol(symbol);
85+
let current = symbol;
86+
// eslint-disable-next-line no-bitwise
87+
while (current.flags & TS_ALIAS_FLAG) {
88+
const reason = getJsDocDeprecation(current);
89+
if (reason !== undefined) {
90+
return reason;
91+
}
92+
const immediateAliasedSymbol =
93+
current.getDeclarations() && checker.getImmediateAliasedSymbol(current);
94+
if (!immediateAliasedSymbol) {
95+
break;
96+
}
97+
current = immediateAliasedSymbol;
98+
if (checkAliasedSymbol && current === targetSymbol) {
99+
return getJsDocDeprecation(current);
100+
}
101+
}
102+
return undefined;
103+
}
104+
105+
function checkDeprecatedIdentifier(identifierNode, scope) {
106+
const ref = scope.references.find((v) => v.identifier === identifierNode);
107+
const variable = ref?.resolved;
108+
const def = variable?.defs[0];
109+
110+
if (!def || def.type !== 'ImportBinding') {
111+
return;
112+
}
113+
114+
const tsNode = services.esTreeNodeToTSNodeMap.get(def.node);
115+
if (!tsNode) {
116+
return;
117+
}
118+
119+
// ImportClause and ImportSpecifier require .name for getSymbolAtLocation
120+
const tsIdentifier = tsNode.name ?? tsNode;
121+
const symbol = checker.getSymbolAtLocation(tsIdentifier);
122+
if (!symbol) {
123+
return;
124+
}
125+
126+
const reason = searchForDeprecationInAliasesChain(symbol, true);
127+
if (reason === undefined) {
128+
return;
129+
}
130+
131+
if (reason === '') {
132+
context.report({
133+
node: identifierNode,
134+
messageId: 'deprecated',
135+
data: { name: identifierNode.name },
136+
});
137+
} else {
138+
context.report({
139+
node: identifierNode,
140+
messageId: 'deprecatedWithReason',
141+
data: { name: identifierNode.name, reason },
142+
});
143+
}
144+
}
145+
146+
return {
147+
GlimmerPathExpression(node) {
148+
checkDeprecatedIdentifier(node.head, sourceCode.getScope(node));
149+
},
150+
151+
GlimmerElementNode(node) {
152+
// GlimmerElementNode is in its own scope; get the outer scope
153+
const scope = sourceCode.getScope(node.parent);
154+
checkDeprecatedIdentifier(node.parts[0], scope);
155+
},
156+
157+
GlimmerAttrNode(node) {
158+
if (!node.name.startsWith('@')) {
159+
return;
160+
}
161+
162+
// Resolve the component import binding from the parent element
163+
const elementNode = node.parent;
164+
const scope = sourceCode.getScope(elementNode.parent);
165+
const ref = scope.references.find((v) => v.identifier === elementNode.parts[0]);
166+
const def = ref?.resolved?.defs[0];
167+
if (!def || def.type !== 'ImportBinding') {
168+
return;
169+
}
170+
171+
const tsNode = services.esTreeNodeToTSNodeMap.get(def.node);
172+
if (!tsNode) {
173+
return;
174+
}
175+
176+
const tsIdentifier = tsNode.name ?? tsNode;
177+
const importSymbol = checker.getSymbolAtLocation(tsIdentifier);
178+
if (!importSymbol) {
179+
return;
180+
}
181+
182+
// Resolve alias to the class symbol
183+
// eslint-disable-next-line no-bitwise
184+
const classSymbol =
185+
importSymbol.flags & TS_ALIAS_FLAG
186+
? checker.getAliasedSymbol(importSymbol)
187+
: importSymbol;
188+
189+
const argsType = getComponentArgsType(classSymbol);
190+
if (!argsType) {
191+
return;
192+
}
193+
194+
const argName = node.name.slice(1); // strip leading '@'
195+
const argSymbol = argsType.getProperty(argName);
196+
const reason = getJsDocDeprecation(argSymbol);
197+
if (reason === undefined) {
198+
return;
199+
}
200+
201+
if (reason === '') {
202+
context.report({
203+
node,
204+
messageId: 'deprecated',
205+
data: { name: node.name },
206+
});
207+
} else {
208+
context.report({
209+
node,
210+
messageId: 'deprecatedWithReason',
211+
data: { name: node.name, reason },
212+
});
213+
}
214+
},
215+
};
216+
},
217+
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default class ComponentBase<S extends object = object> {
2+
declare args: S;
3+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import ComponentBase from './component-stub';
2+
3+
export default class ComponentWithArgs extends ComponentBase<{
4+
Args: {
5+
/** @deprecated use newArg instead */
6+
oldArg: string;
7+
/** @deprecated */
8+
oldArgNoReason: string;
9+
newArg: string;
10+
};
11+
}> {}
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 {}
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
/** @deprecated */
2+
export function deprecatedHelper(): string {
3+
return 'deprecated';
4+
}
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
// Placeholder file — actual code is provided inline by tests.
2+
// Its presence lets TypeScript include this path in the program.

0 commit comments

Comments
 (0)