Skip to content
Merged
Show file tree
Hide file tree
Changes from 8 commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 \<template\> | ![gjs logo](/docs/svgs/gjs.svg) ![gts logo](/docs/svgs/gts.svg) | | |

### jQuery
Expand Down
60 changes: 60 additions & 0 deletions docs/rules/template-no-deprecated.md
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`.

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this worries me as not all coomponents/helpers modifiers will be imported
gjs format allows for multiple components in a single file as well as in scope helpers modifiers or declared on the class itself

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The scope tracking should handle that


**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.

## 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>
```
131 changes: 131 additions & 0 deletions lib/rules/template-no-deprecated.js
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';
}
2 changes: 2 additions & 0 deletions tests/lib/rules-preprocessor/template-no-deprecated/usage.gts
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.
116 changes: 116 additions & 0 deletions tests/lib/rules/template-no-deprecated.js
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
{
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: `
<template><div></div></template>
`,
},
// this.something — no scope reference
{
filename: path.join(FIXTURES_DIR, 'usage.gts'),
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
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

should also test the sub-expression syntax.

e.g.: {{fn (deprecatedFn 1 2)}}

{
filename: path.join(FIXTURES_DIR, 'usage.gts'),
code: `
import { deprecatedHelper } from './deprecated-helper';
<template>{{deprecatedHelper}}</template>
`,
output: null,
errors: [{ messageId: 'deprecated', type: 'VarHead' }],
Copy link
Contributor

Choose a reason for hiding this comment

The 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' }],
},
],
});
Loading