-
-
Notifications
You must be signed in to change notification settings - Fork 210
Add rule: ember/template-no-deprecated #2448
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
NullVoxPopuli
merged 18 commits into
ember-cli:master
from
wagenet:pwn/template-no-deprecated
Feb 27, 2026
Merged
Changes from all commits
Commits
Show all changes
18 commits
Select commit
Hold shift + click to select a range
a80c119
Add rule: ember/template-no-deprecated
wagenet 726ec0b
Fix CI lint failures and remove rule from recommended configs
wagenet a6cd56a
Address PR review feedback
wagenet a810190
Restore fixture files needed for TypeScript type resolution
wagenet 4fbd7e5
Collapse invisible whitespace in template-no-deprecated tests
wagenet ce31d4e
Fix lint: use single-quoted strings in test valid cases
wagenet 79e1127
Expand comment on TS_ALIAS_FLAG with historical context
wagenet d64c881
Fix Prettier formatting in template-no-deprecated test
wagenet 111f9e4
Simplify template-no-deprecated docs to be user-facing
wagenet 5f4dd35
Fix misleading reason for uncovered arg deprecation case
wagenet 40d3c76
Add @deprecated arg detection to template-no-deprecated
wagenet e76e5f9
Fix Prettier formatting in template-no-deprecated test
wagenet c410eb6
Initial plan
Copilot 2df429d
Initial plan
Copilot 58d3dfd
Fix review comments: update docs, clean up test whitespace, add sub-e…
Copilot 9c73644
Revert package-lock.json addition to .gitignore
Copilot 7d0e5f0
Merge pull request #1 from wagenet/copilot/fix-comments-after-commit
wagenet 4ee1869
Apply suggestion from @NullVoxPopuli
NullVoxPopuli File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,59 @@ | ||
| # ember/template-no-deprecated | ||
|
|
||
| <!-- end auto-generated rule header --> | ||
|
|
||
| Disallows using components, helpers, or modifiers that are marked `@deprecated` in their JSDoc. | ||
|
|
||
| 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. | ||
|
|
||
| ## Rule Details | ||
|
|
||
| This rule checks if imported Glimmer components, helpers, or modifiers are marked `@deprecated` in their JSDoc. | ||
|
|
||
| **Covered syntax:** | ||
|
|
||
| | Template syntax | Example | | ||
| | ----------------------- | ------------------------------------------- | | ||
| | Component element | `<DeprecatedComponent />` | | ||
| | Helper / value mustache | `{{deprecatedHelper}}` | | ||
| | Block component | `{{#DeprecatedBlock}}…{{/DeprecatedBlock}}` | | ||
| | Modifier | `<div {{deprecatedModifier}}>` | | ||
| | Component argument | `<MyComp @deprecatedArg={{x}}>` | | ||
|
|
||
| ## Examples | ||
|
|
||
| Given a module: | ||
|
|
||
| ```ts | ||
| // deprecated-component.ts | ||
| /** @deprecated use NewComponent instead */ | ||
| export default class DeprecatedComponent {} | ||
| ``` | ||
|
|
||
| Examples of **incorrect** code for this rule: | ||
|
|
||
| ```gts | ||
| import DeprecatedComponent from './deprecated-component'; | ||
|
|
||
| <template> | ||
| <DeprecatedComponent /> | ||
| </template> | ||
| ``` | ||
|
|
||
| ```gts | ||
| import { deprecatedHelper } from './deprecated-helper'; | ||
|
|
||
| <template> | ||
| {{deprecatedHelper}} | ||
| </template> | ||
| ``` | ||
|
|
||
| Examples of **correct** code for this rule: | ||
|
|
||
| ```gts | ||
| import NewComponent from './new-component'; | ||
|
|
||
| <template> | ||
| <NewComponent /> | ||
| </template> | ||
| ``` |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,217 @@ | ||
| 'use strict'; | ||
|
|
||
| // ts.SymbolFlags.Alias = 2097152 (1 << 21). | ||
| // Hardcoded to avoid adding a direct `typescript` dependency. This value has | ||
| // been stable since TypeScript was open-sourced (~2014) but is not formally | ||
| // guaranteed. If it ever changes, this rule will need to require the user's | ||
| // installed TypeScript and read ts.SymbolFlags.Alias at runtime. | ||
| const TS_ALIAS_FLAG = 2_097_152; | ||
|
|
||
| /** @type {import('eslint').Rule.RuleModule} */ | ||
| module.exports = { | ||
| meta: { | ||
| type: 'problem', | ||
| docs: { | ||
| description: | ||
| 'disallow using deprecated Glimmer components, helpers, and modifiers in templates', | ||
| category: 'Ember Octane', | ||
| url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/template-no-deprecated.md', | ||
| }, | ||
| schema: [], | ||
| messages: { | ||
| deprecated: '`{{name}}` is deprecated.', | ||
| deprecatedWithReason: '`{{name}}` is deprecated. {{reason}}', | ||
| }, | ||
| }, | ||
|
|
||
| create(context) { | ||
| const services = context.sourceCode.parserServices ?? context.parserServices; | ||
| if (!services?.program) { | ||
| return {}; | ||
| } | ||
|
|
||
| const checker = services.program.getTypeChecker(); | ||
| const sourceCode = context.sourceCode; | ||
|
|
||
| // Cache component class symbol → Args object type (null = no Args) per lint run. | ||
| const argsTypeCache = new Map(); | ||
|
|
||
| function getComponentArgsType(classSymbol) { | ||
| if (argsTypeCache.has(classSymbol)) { | ||
| return argsTypeCache.get(classSymbol); | ||
| } | ||
| let result = null; | ||
| try { | ||
| const declaredType = checker.getDeclaredTypeOfSymbol(classSymbol); | ||
| const baseTypes = checker.getBaseTypes(declaredType); | ||
| outer: for (const base of baseTypes) { | ||
| for (const arg of checker.getTypeArguments(base) ?? []) { | ||
| const argsSymbol = arg.getProperty('Args'); | ||
| if (argsSymbol) { | ||
| result = checker.getTypeOfSymbol(argsSymbol); | ||
| break outer; | ||
| } | ||
| } | ||
| } | ||
| } catch { | ||
| result = null; | ||
| } | ||
| argsTypeCache.set(classSymbol, result); | ||
| return result; | ||
| } | ||
|
|
||
| function getJsDocDeprecation(symbol) { | ||
| let jsDocTags; | ||
| try { | ||
| jsDocTags = symbol?.getJsDocTags(checker); | ||
| } catch { | ||
| // workaround for https://github.com/microsoft/TypeScript/issues/60024 | ||
| return undefined; | ||
| } | ||
| const tag = jsDocTags?.find((t) => t.name === 'deprecated'); | ||
| if (!tag) { | ||
| return undefined; | ||
| } | ||
| const displayParts = tag.text; | ||
| return displayParts ? displayParts.map((p) => p.text).join('') : ''; | ||
| } | ||
|
|
||
| function searchForDeprecationInAliasesChain(symbol, checkAliasedSymbol) { | ||
| // eslint-disable-next-line no-bitwise | ||
| if (!symbol || !(symbol.flags & TS_ALIAS_FLAG)) { | ||
| return checkAliasedSymbol ? getJsDocDeprecation(symbol) : undefined; | ||
| } | ||
| const targetSymbol = checker.getAliasedSymbol(symbol); | ||
| let current = symbol; | ||
| // eslint-disable-next-line no-bitwise | ||
| while (current.flags & TS_ALIAS_FLAG) { | ||
| const reason = getJsDocDeprecation(current); | ||
| if (reason !== undefined) { | ||
| return reason; | ||
| } | ||
| const immediateAliasedSymbol = | ||
| current.getDeclarations() && checker.getImmediateAliasedSymbol(current); | ||
| if (!immediateAliasedSymbol) { | ||
| break; | ||
| } | ||
| current = immediateAliasedSymbol; | ||
| if (checkAliasedSymbol && current === targetSymbol) { | ||
| return getJsDocDeprecation(current); | ||
| } | ||
| } | ||
| return undefined; | ||
| } | ||
|
|
||
| function checkDeprecatedIdentifier(identifierNode, scope) { | ||
| const ref = scope.references.find((v) => v.identifier === identifierNode); | ||
| const variable = ref?.resolved; | ||
| const def = variable?.defs[0]; | ||
|
|
||
| if (!def || def.type !== 'ImportBinding') { | ||
| return; | ||
| } | ||
|
|
||
| const tsNode = services.esTreeNodeToTSNodeMap.get(def.node); | ||
| if (!tsNode) { | ||
| return; | ||
| } | ||
|
|
||
| // ImportClause and ImportSpecifier require .name for getSymbolAtLocation | ||
| const tsIdentifier = tsNode.name ?? tsNode; | ||
| const symbol = checker.getSymbolAtLocation(tsIdentifier); | ||
| if (!symbol) { | ||
| return; | ||
| } | ||
|
|
||
| const reason = searchForDeprecationInAliasesChain(symbol, true); | ||
| if (reason === undefined) { | ||
| return; | ||
| } | ||
|
|
||
| if (reason === '') { | ||
| context.report({ | ||
| node: identifierNode, | ||
| messageId: 'deprecated', | ||
| data: { name: identifierNode.name }, | ||
| }); | ||
| } else { | ||
| context.report({ | ||
| node: identifierNode, | ||
| messageId: 'deprecatedWithReason', | ||
| data: { name: identifierNode.name, reason }, | ||
| }); | ||
| } | ||
| } | ||
|
|
||
| return { | ||
| GlimmerPathExpression(node) { | ||
| checkDeprecatedIdentifier(node.head, sourceCode.getScope(node)); | ||
| }, | ||
|
|
||
| GlimmerElementNode(node) { | ||
| // GlimmerElementNode is in its own scope; get the outer scope | ||
| const scope = sourceCode.getScope(node.parent); | ||
| checkDeprecatedIdentifier(node.parts[0], scope); | ||
| }, | ||
|
|
||
| GlimmerAttrNode(node) { | ||
| if (!node.name.startsWith('@')) { | ||
| return; | ||
| } | ||
|
|
||
| // Resolve the component import binding from the parent element | ||
| const elementNode = node.parent; | ||
| const scope = sourceCode.getScope(elementNode.parent); | ||
| const ref = scope.references.find((v) => v.identifier === elementNode.parts[0]); | ||
| const def = ref?.resolved?.defs[0]; | ||
| if (!def || def.type !== 'ImportBinding') { | ||
| return; | ||
| } | ||
|
|
||
| const tsNode = services.esTreeNodeToTSNodeMap.get(def.node); | ||
| if (!tsNode) { | ||
| return; | ||
| } | ||
|
|
||
| const tsIdentifier = tsNode.name ?? tsNode; | ||
| const importSymbol = checker.getSymbolAtLocation(tsIdentifier); | ||
| if (!importSymbol) { | ||
| return; | ||
| } | ||
|
|
||
| // Resolve alias to the class symbol | ||
| // eslint-disable-next-line no-bitwise | ||
| const classSymbol = | ||
| importSymbol.flags & TS_ALIAS_FLAG | ||
| ? checker.getAliasedSymbol(importSymbol) | ||
| : importSymbol; | ||
|
|
||
| const argsType = getComponentArgsType(classSymbol); | ||
| if (!argsType) { | ||
| return; | ||
| } | ||
|
|
||
| const argName = node.name.slice(1); // strip leading '@' | ||
| const argSymbol = argsType.getProperty(argName); | ||
| const reason = getJsDocDeprecation(argSymbol); | ||
| if (reason === undefined) { | ||
| return; | ||
| } | ||
|
|
||
| if (reason === '') { | ||
| context.report({ | ||
| node, | ||
| messageId: 'deprecated', | ||
| data: { name: node.name }, | ||
| }); | ||
| } else { | ||
| context.report({ | ||
| node, | ||
| messageId: 'deprecatedWithReason', | ||
| data: { name: node.name, reason }, | ||
| }); | ||
| } | ||
| }, | ||
| }; | ||
| }, | ||
| }; |
3 changes: 3 additions & 0 deletions
3
tests/lib/rules-preprocessor/template-no-deprecated/component-stub.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,3 @@ | ||
| export default class ComponentBase<S extends object = object> { | ||
| declare args: S; | ||
| } |
11 changes: 11 additions & 0 deletions
11
tests/lib/rules-preprocessor/template-no-deprecated/component-with-args.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,11 @@ | ||
| import ComponentBase from './component-stub'; | ||
|
|
||
| export default class ComponentWithArgs extends ComponentBase<{ | ||
| Args: { | ||
| /** @deprecated use newArg instead */ | ||
| oldArg: string; | ||
| /** @deprecated */ | ||
| oldArgNoReason: string; | ||
| newArg: string; | ||
| }; | ||
| }> {} |
1 change: 1 addition & 0 deletions
1
tests/lib/rules-preprocessor/template-no-deprecated/current-component.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export default class CurrentComponent {} |
2 changes: 2 additions & 0 deletions
2
tests/lib/rules-preprocessor/template-no-deprecated/deprecated-component.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| /** @deprecated use NewComponent instead */ | ||
| export default class DeprecatedComponent {} |
4 changes: 4 additions & 0 deletions
4
tests/lib/rules-preprocessor/template-no-deprecated/deprecated-helper.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| /** @deprecated */ | ||
| export function deprecatedHelper(): string { | ||
| return 'deprecated'; | ||
| } |
2 changes: 2 additions & 0 deletions
2
tests/lib/rules-preprocessor/template-no-deprecated/usage.gts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| // Placeholder file — actual code is provided inline by tests. | ||
| // Its presence lets TypeScript include this path in the program. |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.