Skip to content

Commit 40d3c76

Browse files
wagenetclaude
andcommitted
Add @deprecated arg detection to template-no-deprecated
Detects deprecated component arguments in Glimmer templates via a new GlimmerAttrNode visitor. Navigates the TypeScript type chain from the component's import symbol through getBaseTypes/getTypeArguments to the Args object, then checks getJsDocTags on the specific arg's symbol. Components without typed Args are silently skipped. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
1 parent 5f4dd35 commit 40d3c76

4 files changed

Lines changed: 136 additions & 0 deletions

File tree

lib/rules/template-no-deprecated.js

Lines changed: 86 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,33 @@ module.exports = {
3333
const checker = services.program.getTypeChecker();
3434
const sourceCode = context.sourceCode;
3535

36+
// Cache component class symbol → Args object type (null = no Args) per lint run.
37+
const argsTypeCache = new Map();
38+
39+
function getComponentArgsType(classSymbol) {
40+
if (argsTypeCache.has(classSymbol)) {
41+
return argsTypeCache.get(classSymbol);
42+
}
43+
let result = null;
44+
try {
45+
const declaredType = checker.getDeclaredTypeOfSymbol(classSymbol);
46+
const baseTypes = checker.getBaseTypes(declaredType);
47+
outer: for (const base of baseTypes) {
48+
for (const arg of checker.getTypeArguments(base) ?? []) {
49+
const argsSymbol = arg.getProperty('Args');
50+
if (argsSymbol) {
51+
result = checker.getTypeOfSymbol(argsSymbol);
52+
break outer;
53+
}
54+
}
55+
}
56+
} catch {
57+
result = null;
58+
}
59+
argsTypeCache.set(classSymbol, result);
60+
return result;
61+
}
62+
3663
function getJsDocDeprecation(symbol) {
3764
let jsDocTags;
3865
try {
@@ -126,6 +153,65 @@ module.exports = {
126153
const scope = sourceCode.getScope(node.parent);
127154
checkDeprecatedIdentifier(node.parts[0], scope);
128155
},
156+
157+
GlimmerAttrNode(node) {
158+
if (!node.name.startsWith('@')) {
159+
return;
160+
}
161+
162+
// Resolve the component import binding from the parent element
163+
const elementNode = node.parent;
164+
const scope = sourceCode.getScope(elementNode.parent);
165+
const ref = scope.references.find((v) => v.identifier === elementNode.parts[0]);
166+
const def = ref?.resolved?.defs[0];
167+
if (!def || def.type !== 'ImportBinding') {
168+
return;
169+
}
170+
171+
const tsNode = services.esTreeNodeToTSNodeMap.get(def.node);
172+
if (!tsNode) {
173+
return;
174+
}
175+
176+
const tsIdentifier = tsNode.name ?? tsNode;
177+
const importSymbol = checker.getSymbolAtLocation(tsIdentifier);
178+
if (!importSymbol) {
179+
return;
180+
}
181+
182+
// Resolve alias to the class symbol
183+
// eslint-disable-next-line no-bitwise
184+
const classSymbol =
185+
importSymbol.flags & TS_ALIAS_FLAG
186+
? checker.getAliasedSymbol(importSymbol)
187+
: importSymbol;
188+
189+
const argsType = getComponentArgsType(classSymbol);
190+
if (!argsType) {
191+
return;
192+
}
193+
194+
const argName = node.name.slice(1); // strip leading '@'
195+
const argSymbol = argsType.getProperty(argName);
196+
const reason = getJsDocDeprecation(argSymbol);
197+
if (reason === undefined) {
198+
return;
199+
}
200+
201+
if (reason === '') {
202+
context.report({
203+
node,
204+
messageId: 'deprecated',
205+
data: { name: node.name },
206+
});
207+
} else {
208+
context.report({
209+
node,
210+
messageId: 'deprecatedWithReason',
211+
data: { name: node.name, reason },
212+
});
213+
}
214+
},
129215
};
130216
},
131217
};
Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
export default class ComponentBase<S extends object = object> {
2+
declare args: S;
3+
}
Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
import ComponentBase from './component-stub';
2+
3+
export default class ComponentWithArgs extends ComponentBase<{
4+
Args: {
5+
/** @deprecated use newArg instead */
6+
oldArg: string;
7+
/** @deprecated */
8+
oldArgNoReason: string;
9+
newArg: string;
10+
};
11+
}> {}

tests/lib/rules/template-no-deprecated.js

Lines changed: 36 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -80,6 +80,22 @@ ruleTesterTyped.run('template-no-deprecated (with TS project)', rule, {
8080
<template>{{this.foo}}</template>
8181
`,
8282
},
83+
// Non-deprecated @arg — no error
84+
{
85+
filename: path.join(FIXTURES_DIR, 'usage.gts'),
86+
code: `
87+
import ComponentWithArgs from './component-with-args';
88+
<template><ComponentWithArgs @newArg='x' /></template>
89+
`,
90+
},
91+
// @arg on a component with no typed Args — silently skip
92+
{
93+
filename: path.join(FIXTURES_DIR, 'usage.gts'),
94+
code: `
95+
import CurrentComponent from './current-component';
96+
<template><CurrentComponent @anyArg='x' /></template>
97+
`,
98+
},
8399
],
84100
invalid: [
85101
// Deprecated component in element position
@@ -112,5 +128,25 @@ ruleTesterTyped.run('template-no-deprecated (with TS project)', rule, {
112128
output: null,
113129
errors: [{ messageId: 'deprecatedWithReason', type: 'VarHead' }],
114130
},
131+
// Deprecated @arg with reason
132+
{
133+
filename: path.join(FIXTURES_DIR, 'usage.gts'),
134+
code: `
135+
import ComponentWithArgs from './component-with-args';
136+
<template><ComponentWithArgs @oldArg='x' /></template>
137+
`,
138+
output: null,
139+
errors: [{ messageId: 'deprecatedWithReason', data: { name: '@oldArg', reason: 'use newArg instead' } }],
140+
},
141+
// Deprecated @arg without reason
142+
{
143+
filename: path.join(FIXTURES_DIR, 'usage.gts'),
144+
code: `
145+
import ComponentWithArgs from './component-with-args';
146+
<template><ComponentWithArgs @oldArgNoReason='x' /></template>
147+
`,
148+
output: null,
149+
errors: [{ messageId: 'deprecated', data: { name: '@oldArgNoReason' } }],
150+
},
115151
],
116152
});

0 commit comments

Comments
 (0)