diff --git a/README.md b/README.md index ae2c7b3b6c..4634b8157d 100644 --- a/README.md +++ b/README.md @@ -291,6 +291,7 @@ rules in templates can be disabled with eslint directives with mustache or html | [no-empty-glimmer-component-classes](docs/rules/no-empty-glimmer-component-classes.md) | disallow empty backing classes for Glimmer components | ✅ | | | | [no-tracked-properties-from-args](docs/rules/no-tracked-properties-from-args.md) | disallow creating @tracked properties from this.args | ✅ | | | | [template-indent](docs/rules/template-indent.md) | enforce consistent indentation for gts/gjs templates | | 🔧 | | +| [template-no-deprecated](docs/rules/template-no-deprecated.md) | disallow using deprecated Glimmer components, helpers, and modifiers in templates | | | | | [template-no-let-reference](docs/rules/template-no-let-reference.md) | disallow referencing let variables in \ | ![gjs logo](/docs/svgs/gjs.svg) ![gts logo](/docs/svgs/gts.svg) | | | ### jQuery diff --git a/docs/rules/template-no-deprecated.md b/docs/rules/template-no-deprecated.md new file mode 100644 index 0000000000..3cf531d5b2 --- /dev/null +++ b/docs/rules/template-no-deprecated.md @@ -0,0 +1,59 @@ +# ember/template-no-deprecated + + + +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 | `` | +| Helper / value mustache | `{{deprecatedHelper}}` | +| Block component | `{{#DeprecatedBlock}}…{{/DeprecatedBlock}}` | +| Modifier | `
` | +| Component argument | `` | + +## 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'; + + +``` + +```gts +import { deprecatedHelper } from './deprecated-helper'; + + +``` + +Examples of **correct** code for this rule: + +```gts +import NewComponent from './new-component'; + + +``` diff --git a/lib/rules/template-no-deprecated.js b/lib/rules/template-no-deprecated.js new file mode 100644 index 0000000000..95567ff5bf --- /dev/null +++ b/lib/rules/template-no-deprecated.js @@ -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 }, + }); + } + }, + }; + }, +}; diff --git a/tests/lib/rules-preprocessor/template-no-deprecated/component-stub.ts b/tests/lib/rules-preprocessor/template-no-deprecated/component-stub.ts new file mode 100644 index 0000000000..0cb2ddb101 --- /dev/null +++ b/tests/lib/rules-preprocessor/template-no-deprecated/component-stub.ts @@ -0,0 +1,3 @@ +export default class ComponentBase { + declare args: S; +} diff --git a/tests/lib/rules-preprocessor/template-no-deprecated/component-with-args.ts b/tests/lib/rules-preprocessor/template-no-deprecated/component-with-args.ts new file mode 100644 index 0000000000..b7db3fa51f --- /dev/null +++ b/tests/lib/rules-preprocessor/template-no-deprecated/component-with-args.ts @@ -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; + }; +}> {} diff --git a/tests/lib/rules-preprocessor/template-no-deprecated/current-component.ts b/tests/lib/rules-preprocessor/template-no-deprecated/current-component.ts new file mode 100644 index 0000000000..5e9ba0eac2 --- /dev/null +++ b/tests/lib/rules-preprocessor/template-no-deprecated/current-component.ts @@ -0,0 +1 @@ +export default class CurrentComponent {} diff --git a/tests/lib/rules-preprocessor/template-no-deprecated/deprecated-component.ts b/tests/lib/rules-preprocessor/template-no-deprecated/deprecated-component.ts new file mode 100644 index 0000000000..f3a72e51d2 --- /dev/null +++ b/tests/lib/rules-preprocessor/template-no-deprecated/deprecated-component.ts @@ -0,0 +1,2 @@ +/** @deprecated use NewComponent instead */ +export default class DeprecatedComponent {} diff --git a/tests/lib/rules-preprocessor/template-no-deprecated/deprecated-helper.ts b/tests/lib/rules-preprocessor/template-no-deprecated/deprecated-helper.ts new file mode 100644 index 0000000000..bbebe6235a --- /dev/null +++ b/tests/lib/rules-preprocessor/template-no-deprecated/deprecated-helper.ts @@ -0,0 +1,4 @@ +/** @deprecated */ +export function deprecatedHelper(): string { + return 'deprecated'; +} diff --git a/tests/lib/rules-preprocessor/template-no-deprecated/usage.gts b/tests/lib/rules-preprocessor/template-no-deprecated/usage.gts new file mode 100644 index 0000000000..2df5fd9f47 --- /dev/null +++ b/tests/lib/rules-preprocessor/template-no-deprecated/usage.gts @@ -0,0 +1,2 @@ +// Placeholder file — actual code is provided inline by tests. +// Its presence lets TypeScript include this path in the program. diff --git a/tests/lib/rules/template-no-deprecated.js b/tests/lib/rules/template-no-deprecated.js new file mode 100644 index 0000000000..55c6b4ef02 --- /dev/null +++ b/tests/lib/rules/template-no-deprecated.js @@ -0,0 +1,136 @@ +'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", + // Plain HTML tag -- never reported + '', + // this.something -- not a scope reference + '', + // Undefined reference -- no def, skip + '', + ], + 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 + { + filename: path.join(FIXTURES_DIR, 'usage.gts'), + code: "import CurrentComponent from './current-component';\n", + }, + // Plain HTML tag + { + filename: path.join(FIXTURES_DIR, 'usage.gts'), + code: '', + }, + // this.something — no scope reference + { + filename: path.join(FIXTURES_DIR, 'usage.gts'), + code: '', + }, + // Non-deprecated @arg + { + filename: path.join(FIXTURES_DIR, 'usage.gts'), + code: "import ComponentWithArgs from './component-with-args';\n", + }, + // @arg on a component with no typed Args + { + filename: path.join(FIXTURES_DIR, 'usage.gts'), + code: "import CurrentComponent from './current-component';\n", + }, + ], + invalid: [ + // Deprecated component in element position + { + filename: path.join(FIXTURES_DIR, 'usage.gts'), + code: "import DeprecatedComponent from './deprecated-component';\n", + output: null, + errors: [{ messageId: 'deprecatedWithReason', type: 'GlimmerElementNodePart' }], + }, + // Deprecated helper in mustache position + { + filename: path.join(FIXTURES_DIR, 'usage.gts'), + code: "import { deprecatedHelper } from './deprecated-helper';\n", + output: null, + errors: [{ messageId: 'deprecated', type: 'VarHead' }], + }, + // Deprecated helper in sub-expression position + { + filename: path.join(FIXTURES_DIR, 'usage.gts'), + code: "import { deprecatedHelper } from './deprecated-helper';\n", + output: null, + errors: [{ messageId: 'deprecated', type: 'VarHead' }], + }, + // Deprecated component in block position + { + filename: path.join(FIXTURES_DIR, 'usage.gts'), + code: "import DeprecatedComponent from './deprecated-component';\n", + output: null, + errors: [{ messageId: 'deprecatedWithReason', type: 'VarHead' }], + }, + // Deprecated @arg with reason + { + filename: path.join(FIXTURES_DIR, 'usage.gts'), + code: "import ComponentWithArgs from './component-with-args';\n", + output: null, + errors: [ + { + messageId: 'deprecatedWithReason', + data: { name: '@oldArg', reason: 'use newArg instead' }, + }, + ], + }, + // Deprecated @arg without reason + { + filename: path.join(FIXTURES_DIR, 'usage.gts'), + code: "import ComponentWithArgs from './component-with-args';\n", + output: null, + errors: [{ messageId: 'deprecated', data: { name: '@oldArgNoReason' } }], + }, + ], +});