Skip to content
Merged
Show file tree
Hide file tree
Changes from 5 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.
Comment thread
NullVoxPopuli marked this conversation as resolved.
Outdated

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`.
Copy link
Copy Markdown

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
Copy Markdown
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

Comment thread
wagenet marked this conversation as resolved.
Outdated

**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.
Comment thread
wagenet marked this conversation as resolved.
Outdated

## 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>
```
127 changes: 127 additions & 0 deletions lib/rules/template-no-deprecated.js
Original file line number Diff line number Diff line change
@@ -0,0 +1,127 @@
'use strict';

// ts.SymbolFlags.Alias = 2097152 (1 << 21) — stable across all TypeScript versions
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

is this actually stable across all TS versions? do we have that guarantee?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

❯ "ts.SymbolFlags.Alias = 2097152 (1 << 21) — stable across all TypeScript versions" is this guaranteed?

⏺ No. TypeScript's compiler API has no stability guarantee — SymbolFlags is a const enum and values can shift if flags are inserted. The comment overstates it.

In practice, Alias = 1 << 21 has been unchanged since TypeScript was open-sourced (~2014) and is very unlikely to move because it would silently break every @typescript-eslint/* rule that bit-tests it. But "unlikely to change" ≠ "guaranteed".

The defensible fix is to read the value from the TypeScript module the user has installed rather than hardcoding it:

// Get ts from the program's internal reference to avoid adding a direct dependency
const ts = services.program.getCompilerOptions().configFilePath
? require('typescript')
: undefined;
const TS_ALIAS_FLAG = ts?.SymbolFlags?.Alias ?? 2_097_152;

But that's awkward. The cleaner options are:

  1. Add typescript as a peer dependency (already required for typed linting) and require('typescript') directly — this is what @typescript-eslint rules do.
  2. Keep the hardcoded value but fix the comment to say "stable in practice since TypeScript 1.x, not formally guaranteed" — honest and low-risk.

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

let's keep the hard-coded value, but add this context into the file, as it's important historical context, and may one day requires us require the user's typescript

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
Comment thread
NullVoxPopuli marked this conversation as 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.
116 changes: 116 additions & 0 deletions tests/lib/rules/template-no-deprecated.js
Comment thread
NullVoxPopuli marked this conversation as 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>`,

Check failure on line 20 in tests/lib/rules/template-no-deprecated.js

View workflow job for this annotation

GitHub Actions / self-lint

Strings must use singlequote
// Plain HTML tag -- never reported
`<template><div></div></template>`,

Check failure on line 22 in tests/lib/rules/template-no-deprecated.js

View workflow job for this annotation

GitHub Actions / self-lint

Strings must use singlequote
// this.something -- not a scope reference
`<template>{{this.foo}}</template>`,

Check failure on line 24 in tests/lib/rules/template-no-deprecated.js

View workflow job for this annotation

GitHub Actions / self-lint

Strings must use singlequote
// Undefined reference -- no def, skip
`<template>{{undefinedThing}}</template>`,

Check failure on line 26 in tests/lib/rules/template-no-deprecated.js

View workflow job for this annotation

GitHub Actions / self-lint

Strings must use singlequote
],
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
Comment thread
wagenet marked this conversation as resolved.
Outdated
{
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: `
Comment thread
wagenet marked this conversation as resolved.
Outdated
<template><div></div></template>
`,
},
// this.something — no scope reference
{
filename: path.join(FIXTURES_DIR, 'usage.gts'),
Comment thread
NullVoxPopuli marked this conversation as 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
Copy link
Copy Markdown
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
Copy Markdown
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