Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
79 changes: 79 additions & 0 deletions docs/arg-deprecation-future-work.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
# Future Work: `@arg` Deprecation in Templates

This document tracks the unimplemented case for `ember/template-no-deprecated`:
detecting `@deprecated` on component arguments passed in template syntax.

## The Problem

`<MyComponent @deprecatedArg={{x}}>` — `@deprecatedArg` appears as a `GlimmerAttrNode`.
It is never scope-registered (the parser skips args, see `ember-eslint-parser/src/parser/transforms.js`
around line 504). The scope-based approach used in `template-no-deprecated` does not apply here.

## The Analogous Solved Problem

`@typescript-eslint/no-deprecated` handles JSX attributes via `getJSXAttributeDeprecation`
(`no-deprecated.js:266–271`):

```js
function getJSXAttributeDeprecation(openingElement, propertyName) {
const tsNode = services.esTreeNodeToTSNodeMap.get(openingElement.name);
const contextualType = checker.getContextualType(tsNode);
const symbol = contextualType.getProperty(propertyName);
return getJsDocDeprecation(symbol);
}
```

This works because `JSXOpeningElement.name` is a standard TS AST node, so
`esTreeNodeToTSNodeMap` has it, and TypeScript's JSX support provides `getContextualType`
for JSX element names (which gives the props type directly).

## Why This Doesn't Directly Apply to Glimmer

`GlimmerElementNode.parts[0]` (`GlimmerElementNodePart`) is a **synthetic node** invented by
`ember-eslint-parser`. It is not in `esTreeNodeToTSNodeMap`. Therefore:

- `services.esTreeNodeToTSNodeMap.get(parts[0])` → `undefined`
- `checker.getContextualType(undefined)` → crash

## Options

### Option A: Navigate the Type Manually (no Glint required)

1. Visit `GlimmerAttrNode` where `node.name.startsWith('@')`
2. Get parent `GlimmerElementNode`, resolve its import binding (same as `template-no-deprecated`)
3. `services.esTreeNodeToTSNodeMap.get(importDefNode)` → TS import node
4. `checker.getTypeAtLocation(tsImportNode)` → component's constructor type
5. Navigate type parameters to extract the `Args` type:
- Get base type `Component<{Args: A}>` → extract first type argument → get `A`
- Requires walking `type.typeArguments` and the `Args` property
6. `argsType.getProperty(argName.slice(1))` → property symbol
7. `getJsDocDeprecation(symbol)` → check for `@deprecated`

Self-contained (no Glint needed) but TypeScript type navigation is brittle —
it depends on the specific generic signature of `@glimmer/component`.

### Option B: Register `@arg` Nodes in Scope (requires ember-eslint-parser change)

Modify `ember-eslint-parser/src/parser/transforms.js` to register `GlimmerAttrNode` names
starting with `@` as references, using a virtual identifier that IS in `esTreeNodeToTSNodeMap`.
Then the JSX-attribute pattern would work directly.

This is an upstream change to `ember-eslint-parser`, not this plugin.

### Option C: Use Glint v2 Virtual TypeScript Files (future)

Glint v2 (Volar) operates as a tsserver TS plugin. ESLint's `@typescript-eslint/parser`
creates a separate TypeScript program that does **not** load tsserver plugins by default,
so Glint's template type information is not available in `parserServices.program`.

To bridge this: either (1) `ember-eslint-parser` would need to integrate with Glint's
virtual file infrastructure and re-expose the resulting program, or (2) Glint v2 would need
to provide a TypeScript compiler transform (not just a language server plugin) that
`@typescript-eslint/parser` picks up automatically. Neither exists today. File this as a
Glint project concern.

## Recommended Path

Option A is the most tractable near-term approach — no upstream changes required.
The main risk is brittleness against non-standard component base classes.
Option B is architecturally cleaner but requires coordination with `ember-eslint-parser` maintainers.
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>
```
2 changes: 1 addition & 1 deletion lib/recommended-rules.js
Copy link
Contributor

Choose a reason for hiding this comment

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

PR needs cleanup

Original file line number Diff line number Diff line change
Expand Up @@ -75,4 +75,4 @@ module.exports = {
"ember/routes-segments-snake-case": "error",
"ember/use-brace-expansion": "error",
"ember/use-ember-data-rfc-395-imports": "error"
}
};
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
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
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
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);
},
};
},
};
1 change: 1 addition & 0 deletions tests/__snapshots__/recommended.js.snap
Copy link
Contributor

Choose a reason for hiding this comment

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

why's this here?

Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@ exports[`recommended rules > gjs config has the right list 1`] = `

exports[`recommended rules > gts config has the right list 1`] = `
[
"template-no-deprecated",
"template-no-let-reference",
]
`;
Expand Down
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.
Loading
Loading