-
-
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 10 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,60 @@ | ||
| # ember/template-no-deprecated | ||
|
|
||
| <!-- end auto-generated rule header --> | ||
|
|
||
| Disallows using Glimmer 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}}>` | | ||
|
|
||
| **Not covered:** `<MyComp @deprecatedArg={{x}}>` — checking argument deprecations is not yet implemented. | ||
wagenet marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
wagenet marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
|
|
||
| ## 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,131 @@ | ||
| '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; | ||
|
|
||
| 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); | ||
| }, | ||
| }; | ||
| }, | ||
| }; |
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
NullVoxPopuli marked this conversation as resolved.
Show resolved
Hide resolved
|
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. |
NullVoxPopuli marked this conversation as resolved.
Show resolved
Hide resolved
|
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,116 @@ | ||
| 'use strict'; | ||
|
|
||
| const path = require('node:path'); | ||
| const rule = require('../../../lib/rules/template-no-deprecated'); | ||
| const RuleTester = require('eslint').RuleTester; | ||
|
|
||
| const FIXTURES_DIR = path.join(__dirname, '../rules-preprocessor/template-no-deprecated'); | ||
|
|
||
| // Block 1: No TypeScript project -- rule is a no-op | ||
| // When parserServices.program is absent, the rule returns {} and never reports. | ||
|
|
||
| const ruleTester = new RuleTester({ | ||
| parser: require.resolve('ember-eslint-parser'), | ||
| parserOptions: { ecmaVersion: 2022, sourceType: 'module' }, | ||
| }); | ||
|
|
||
| ruleTester.run('template-no-deprecated', rule, { | ||
| valid: [ | ||
| // Non-deprecated component reference | ||
| "import SomeComponent from './some-component';\n<template><SomeComponent /></template>", | ||
| // Plain HTML tag -- never reported | ||
| '<template><div></div></template>', | ||
| // this.something -- not a scope reference | ||
| '<template>{{this.foo}}</template>', | ||
| // Undefined reference -- no def, skip | ||
| '<template>{{undefinedThing}}</template>', | ||
| ], | ||
| invalid: [], | ||
| }); | ||
|
|
||
| // Block 2: TypeScript project -- full deprecation checking | ||
| // | ||
| // Unlike most rule tests, this block requires physical fixture files in | ||
| // tests/lib/rules-preprocessor/template-no-deprecated/. Two reasons: | ||
| // | ||
| // 1. The tsconfig uses glob patterns to build its file list. The `filename` | ||
| // passed to RuleTester must physically exist so TypeScript includes it. | ||
| // | ||
| // 2. This rule only checks ImportBinding definitions. To detect @deprecated, | ||
| // TypeScript must resolve the import and read the JSDoc from the source | ||
| // file. Inline class/function definitions are not checked. | ||
| // | ||
| // Rules that don't use parserOptions.project, or whose logic doesn't depend | ||
| // on TypeScript import resolution, can use any virtual filename. | ||
|
|
||
| const PREPROCESSOR_DIR = path.join(__dirname, '../rules-preprocessor'); | ||
|
|
||
| const ruleTesterTyped = new RuleTester({ | ||
| parser: require.resolve('ember-eslint-parser'), | ||
| parserOptions: { | ||
| project: path.join(PREPROCESSOR_DIR, 'tsconfig.eslint.json'), | ||
| tsconfigRootDir: PREPROCESSOR_DIR, | ||
| ecmaVersion: 2022, | ||
| sourceType: 'module', | ||
| extraFileExtensions: ['.gts'], | ||
| }, | ||
| }); | ||
|
|
||
| ruleTesterTyped.run('template-no-deprecated (with TS project)', rule, { | ||
| valid: [ | ||
| // Non-deprecated component — no error | ||
wagenet marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| { | ||
| filename: path.join(FIXTURES_DIR, 'usage.gts'), | ||
| code: ` | ||
| import CurrentComponent from './current-component'; | ||
| <template><CurrentComponent /></template> | ||
| `, | ||
| }, | ||
| // Plain HTML tag | ||
| { | ||
| filename: path.join(FIXTURES_DIR, 'usage.gts'), | ||
| code: ` | ||
wagenet marked this conversation as resolved.
Outdated
Show resolved
Hide resolved
|
||
| <template><div></div></template> | ||
| `, | ||
| }, | ||
| // this.something — no scope reference | ||
| { | ||
| filename: path.join(FIXTURES_DIR, 'usage.gts'), | ||
NullVoxPopuli marked this conversation as resolved.
Show resolved
Hide resolved
|
||
| code: ` | ||
| <template>{{this.foo}}</template> | ||
| `, | ||
| }, | ||
| ], | ||
| invalid: [ | ||
| // Deprecated component in element position | ||
| { | ||
| filename: path.join(FIXTURES_DIR, 'usage.gts'), | ||
| code: ` | ||
| import DeprecatedComponent from './deprecated-component'; | ||
| <template><DeprecatedComponent /></template> | ||
| `, | ||
| output: null, | ||
| errors: [{ messageId: 'deprecatedWithReason', type: 'GlimmerElementNodePart' }], | ||
| }, | ||
| // Deprecated helper in mustache position | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. should also test the sub-expression syntax. e.g.: |
||
| { | ||
| filename: path.join(FIXTURES_DIR, 'usage.gts'), | ||
| code: ` | ||
| import { deprecatedHelper } from './deprecated-helper'; | ||
| <template>{{deprecatedHelper}}</template> | ||
| `, | ||
| output: null, | ||
| errors: [{ messageId: 'deprecated', type: 'VarHead' }], | ||
|
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| }, | ||
| // Deprecated component in block position | ||
| { | ||
| filename: path.join(FIXTURES_DIR, 'usage.gts'), | ||
| code: ` | ||
| import DeprecatedComponent from './deprecated-component'; | ||
| <template>{{#DeprecatedComponent}}{{/DeprecatedComponent}}</template> | ||
| `, | ||
| output: null, | ||
| errors: [{ messageId: 'deprecatedWithReason', type: 'VarHead' }], | ||
| }, | ||
| ], | ||
| }); | ||
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.