-
-
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
Changes from 8 commits
a80c119
726ec0b
a6cd56a
a810190
4fbd7e5
ce31d4e
79e1127
d64c881
111f9e4
5f4dd35
40d3c76
e76e5f9
c410eb6
2df429d
58d3dfd
9c73644
7d0e5f0
4ee1869
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| 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 | ||
|
|
||
| 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`. | ||
|
||
|
|
||
| **Covered syntax:** | ||
|
|
||
| | Template syntax | Example | | ||
| | ----------------------- | ------------------------------------------- | | ||
| | Component element | `<DeprecatedComponent />` | | ||
| | Helper / value mustache | `{{deprecatedHelper}}` | | ||
| | Block component | `{{#DeprecatedBlock}}…{{/DeprecatedBlock}}` | | ||
| | Modifier | `<div {{deprecatedModifier}}>` | | ||
|
|
||
| **Not covered (see future work):** `<MyComp @deprecatedArg={{x}}>` — argument names are not scope-registered by the parser. | ||
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> | ||
| ``` | ||
| 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); | ||
| }, | ||
| }; | ||
| }, | ||
| }; |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1 @@ | ||
| export default class CurrentComponent {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,2 @@ | ||
| /** @deprecated use NewComponent instead */ | ||
| export default class DeprecatedComponent {} |
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,4 @@ | ||
| /** @deprecated */ | ||
| export function deprecatedHelper(): string { | ||
| return 'deprecated'; | ||
| } |
NullVoxPopuli marked this conversation as resolved.
Show resolved
Hide resolved
|
| 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
|
| 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' }], | ||
| }, | ||
| ], | ||
| }); | ||
Uh oh!
There was an error while loading. Please reload this page.