@discourse/lint-configs 2.44.0 → 2.45.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/eslint-rules/i18n-import-location.mjs +9 -9
- package/eslint-rules/no-computed-macros/computed-macros-analysis.mjs +903 -0
- package/eslint-rules/no-computed-macros/computed-macros-fixer.mjs +612 -0
- package/eslint-rules/no-computed-macros/macro-transforms.mjs +645 -0
- package/eslint-rules/no-computed-macros.mjs +436 -0
- package/eslint-rules/no-discourse-computed.mjs +21 -21
- package/eslint-rules/truth-helpers-imports.mjs +9 -5
- package/eslint-rules/utils/fix-import.mjs +32 -12
- package/eslint.mjs +4 -8
- package/package.json +3 -3
- package/stylelint.mjs +6 -0
|
@@ -0,0 +1,903 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* @fileoverview Analysis helpers for the `no-computed-macros` ESLint rule.
|
|
3
|
+
*
|
|
4
|
+
* Performs read-only AST traversal to detect usages of computed property macros
|
|
5
|
+
* from `@ember/object/computed`, `discourse/lib/computed`, and
|
|
6
|
+
* `discourse/lib/decorators`, determines whether
|
|
7
|
+
* each usage can be auto-fixed, and collects the information the fixer needs.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import {
|
|
11
|
+
isLocalKey,
|
|
12
|
+
MACRO_SOURCES,
|
|
13
|
+
MACRO_TRANSFORMS,
|
|
14
|
+
SOURCE_ALIASES,
|
|
15
|
+
} from "./macro-transforms.mjs";
|
|
16
|
+
|
|
17
|
+
// ---------------------------------------------------------------------------
|
|
18
|
+
// Types
|
|
19
|
+
// ---------------------------------------------------------------------------
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* @typedef {Object} MacroUsage
|
|
23
|
+
* @property {string} macroName - The original macro name (e.g. "alias", "not")
|
|
24
|
+
* @property {string} localName - The local identifier name (may differ due to aliases)
|
|
25
|
+
* @property {import('./macro-transforms.mjs').MacroTransform} transform
|
|
26
|
+
* @property {import('estree').Node} decoratorNode - The Decorator AST node
|
|
27
|
+
* @property {import('estree').Node} propertyNode - The PropertyDefinition AST node
|
|
28
|
+
* @property {string[]} literalArgs - Literal string/number argument values
|
|
29
|
+
* @property {import('estree').Node[]} argNodes - Raw AST argument nodes
|
|
30
|
+
* @property {string} propName - The decorated property name
|
|
31
|
+
* @property {string[]} dependentKeys - Resolved dependent keys
|
|
32
|
+
* @property {boolean} allLocal - Whether all dependent keys are local (no dots)
|
|
33
|
+
* @property {boolean} canAutoFix
|
|
34
|
+
* @property {string} [messageId] - Message ID when not auto-fixable
|
|
35
|
+
* @property {Object} [reportData] - Data for error message interpolation
|
|
36
|
+
* @property {string[]} [trackedDeps] - Local deps needing a NEW @tracked declaration (not existing members)
|
|
37
|
+
* @property {import('estree').Node[]} [existingNodesToDecorate] - Existing PropertyDefinition nodes needing @tracked added
|
|
38
|
+
*/
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* @typedef {Object} MacroAnalysisResult
|
|
42
|
+
* @property {MacroUsage[]} usages - All detected macro usages
|
|
43
|
+
* @property {Map<string, string>} importedMacros - Map from local name → macro name
|
|
44
|
+
* @property {Map<string, import('estree').ImportDeclaration>} macroImportNodes - Import nodes by source
|
|
45
|
+
*/
|
|
46
|
+
|
|
47
|
+
// ---------------------------------------------------------------------------
|
|
48
|
+
// Analysis
|
|
49
|
+
// ---------------------------------------------------------------------------
|
|
50
|
+
|
|
51
|
+
/**
|
|
52
|
+
* Analyze the source AST for computed property macro usage.
|
|
53
|
+
*
|
|
54
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
55
|
+
* @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} imports
|
|
56
|
+
* Result of `collectImports(sourceCode)`.
|
|
57
|
+
* @returns {MacroAnalysisResult}
|
|
58
|
+
*/
|
|
59
|
+
export function analyzeMacroUsage(sourceCode, imports) {
|
|
60
|
+
const importedMacros = collectMacroImports(imports);
|
|
61
|
+
const macroImportNodes = collectMacroImportNodes(imports);
|
|
62
|
+
const usages = [];
|
|
63
|
+
|
|
64
|
+
if (importedMacros.size === 0) {
|
|
65
|
+
return { usages, importedMacros, macroImportNodes };
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
for (const statement of sourceCode.ast.body) {
|
|
69
|
+
walkNode(statement, (node) => {
|
|
70
|
+
// Decorator usage on a PropertyDefinition in a native class
|
|
71
|
+
if (node.type === "PropertyDefinition" && node.decorators) {
|
|
72
|
+
analyzePropertyDefinition(node, importedMacros, usages, sourceCode);
|
|
73
|
+
}
|
|
74
|
+
});
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
// Post-process tracked deps:
|
|
78
|
+
// 1. Remove deps that are themselves macro properties being converted to
|
|
79
|
+
// getters — adding @tracked to something that will become a getter is wrong.
|
|
80
|
+
// 2. Propagate @computed requirement transitively — if a macro depends on
|
|
81
|
+
// another macro that uses @computed, it must also use @computed.
|
|
82
|
+
// 3. Deduplicate so each new @tracked declaration is emitted by one fixer only.
|
|
83
|
+
excludeDepsBeingConverted(usages);
|
|
84
|
+
excludeImplicitInjectionDeps(usages);
|
|
85
|
+
forceComputedForClassicComponents(usages, imports);
|
|
86
|
+
excludeUndeclaredDepsInSubclasses(usages, imports);
|
|
87
|
+
propagateComputedRequirement(usages);
|
|
88
|
+
deduplicateTrackedDeps(usages);
|
|
89
|
+
|
|
90
|
+
return { usages, importedMacros, macroImportNodes };
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
// ---------------------------------------------------------------------------
|
|
94
|
+
// Import collection
|
|
95
|
+
// ---------------------------------------------------------------------------
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Scan the imports map for macro names from all target sources.
|
|
99
|
+
* Returns a map from local identifier name → canonical macro name.
|
|
100
|
+
*
|
|
101
|
+
* @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} imports
|
|
102
|
+
* @returns {Map<string, string>}
|
|
103
|
+
*/
|
|
104
|
+
function collectMacroImports(imports) {
|
|
105
|
+
const result = new Map();
|
|
106
|
+
|
|
107
|
+
for (const [, transform] of MACRO_TRANSFORMS) {
|
|
108
|
+
const importInfo = imports.get(transform.source);
|
|
109
|
+
if (!importInfo) {
|
|
110
|
+
continue;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
for (const spec of importInfo.specifiers) {
|
|
114
|
+
if (spec.type !== "ImportSpecifier") {
|
|
115
|
+
continue;
|
|
116
|
+
}
|
|
117
|
+
const importedName = spec.imported.name;
|
|
118
|
+
if (MACRO_TRANSFORMS.has(importedName)) {
|
|
119
|
+
result.set(spec.local.name, importedName);
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
// Check alias sources (e.g. discourse/lib/decorators → @ember/object/computed)
|
|
125
|
+
for (const [aliasSource, canonicalSource] of SOURCE_ALIASES) {
|
|
126
|
+
const importInfo = imports.get(aliasSource);
|
|
127
|
+
if (!importInfo) {
|
|
128
|
+
continue;
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
for (const spec of importInfo.specifiers) {
|
|
132
|
+
if (spec.type !== "ImportSpecifier") {
|
|
133
|
+
continue;
|
|
134
|
+
}
|
|
135
|
+
const importedName = spec.imported.name;
|
|
136
|
+
const transform = MACRO_TRANSFORMS.get(importedName);
|
|
137
|
+
if (transform && transform.source === canonicalSource) {
|
|
138
|
+
result.set(spec.local.name, importedName);
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
return result;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
/**
|
|
147
|
+
* Collect the ImportDeclaration nodes for each macro source that has macros.
|
|
148
|
+
*
|
|
149
|
+
* @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} imports
|
|
150
|
+
* @returns {Map<string, import('estree').ImportDeclaration>}
|
|
151
|
+
*/
|
|
152
|
+
function collectMacroImportNodes(imports) {
|
|
153
|
+
const result = new Map();
|
|
154
|
+
|
|
155
|
+
for (const source of MACRO_SOURCES) {
|
|
156
|
+
const importInfo = imports.get(source);
|
|
157
|
+
if (importInfo) {
|
|
158
|
+
result.set(source, importInfo.node);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
return result;
|
|
163
|
+
}
|
|
164
|
+
|
|
165
|
+
// ---------------------------------------------------------------------------
|
|
166
|
+
// PropertyDefinition analysis (native class decorators)
|
|
167
|
+
// ---------------------------------------------------------------------------
|
|
168
|
+
|
|
169
|
+
/**
|
|
170
|
+
* Analyze a PropertyDefinition node for macro decorator usage.
|
|
171
|
+
*
|
|
172
|
+
* @param {import('estree').Node} node - PropertyDefinition
|
|
173
|
+
* @param {Map<string, string>} importedMacros
|
|
174
|
+
* @param {MacroUsage[]} usages
|
|
175
|
+
* @param {import('eslint').SourceCode} sourceCode
|
|
176
|
+
*/
|
|
177
|
+
function analyzePropertyDefinition(node, importedMacros, usages, sourceCode) {
|
|
178
|
+
for (const decorator of node.decorators) {
|
|
179
|
+
const expr = decorator.expression;
|
|
180
|
+
|
|
181
|
+
// Must be a CallExpression — e.g. @alias("foo")
|
|
182
|
+
if (expr.type !== "CallExpression") {
|
|
183
|
+
continue;
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
const callee = expr.callee;
|
|
187
|
+
if (callee.type !== "Identifier") {
|
|
188
|
+
continue;
|
|
189
|
+
}
|
|
190
|
+
|
|
191
|
+
const macroName = importedMacros.get(callee.name);
|
|
192
|
+
if (!macroName) {
|
|
193
|
+
continue;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
const transform = MACRO_TRANSFORMS.get(macroName);
|
|
197
|
+
if (!transform) {
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
const propName =
|
|
202
|
+
node.key.type === "Identifier" ? node.key.name : String(node.key.value);
|
|
203
|
+
|
|
204
|
+
const usage = buildUsage({
|
|
205
|
+
macroName,
|
|
206
|
+
localName: callee.name,
|
|
207
|
+
transform,
|
|
208
|
+
decoratorNode: decorator,
|
|
209
|
+
propertyNode: node,
|
|
210
|
+
argNodes: expr.arguments,
|
|
211
|
+
propName,
|
|
212
|
+
sourceCode,
|
|
213
|
+
});
|
|
214
|
+
|
|
215
|
+
usages.push(usage);
|
|
216
|
+
}
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
// ---------------------------------------------------------------------------
|
|
220
|
+
// Usage builder
|
|
221
|
+
// ---------------------------------------------------------------------------
|
|
222
|
+
|
|
223
|
+
/**
|
|
224
|
+
* Build a MacroUsage object from the gathered information, determining
|
|
225
|
+
* fixability and computing dependent keys.
|
|
226
|
+
*
|
|
227
|
+
* @param {Object} params
|
|
228
|
+
* @returns {MacroUsage}
|
|
229
|
+
*/
|
|
230
|
+
function buildUsage({
|
|
231
|
+
macroName,
|
|
232
|
+
localName,
|
|
233
|
+
transform,
|
|
234
|
+
decoratorNode,
|
|
235
|
+
propertyNode,
|
|
236
|
+
argNodes,
|
|
237
|
+
propName,
|
|
238
|
+
sourceCode,
|
|
239
|
+
}) {
|
|
240
|
+
const base = {
|
|
241
|
+
macroName,
|
|
242
|
+
localName,
|
|
243
|
+
transform,
|
|
244
|
+
decoratorNode,
|
|
245
|
+
propertyNode,
|
|
246
|
+
argNodes,
|
|
247
|
+
propName,
|
|
248
|
+
sourceCode,
|
|
249
|
+
};
|
|
250
|
+
|
|
251
|
+
// Not auto-fixable by design (e.g. filter/map with callbacks)
|
|
252
|
+
if (!transform.canAutoFix) {
|
|
253
|
+
return {
|
|
254
|
+
...base,
|
|
255
|
+
literalArgs: [],
|
|
256
|
+
dependentKeys: [],
|
|
257
|
+
allLocal: false,
|
|
258
|
+
canAutoFix: false,
|
|
259
|
+
messageId: "cannotAutoFixComplex",
|
|
260
|
+
reportData: { name: macroName, reason: transform.reason },
|
|
261
|
+
};
|
|
262
|
+
}
|
|
263
|
+
|
|
264
|
+
// Extract literal arguments; bail if any dep-key arg is non-literal.
|
|
265
|
+
// Args beyond `depKeyArgCount` are "value args" — they can be non-literal
|
|
266
|
+
// (their source text is used verbatim in the getter body via sourceCode.getText).
|
|
267
|
+
const literalArgs = [];
|
|
268
|
+
for (let i = 0; i < argNodes.length; i++) {
|
|
269
|
+
const argNode = argNodes[i];
|
|
270
|
+
|
|
271
|
+
// Value args (beyond dep key positions) don't need to be literals
|
|
272
|
+
if (transform.depKeyArgCount != null && i >= transform.depKeyArgCount) {
|
|
273
|
+
literalArgs.push(null);
|
|
274
|
+
continue;
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
if (argNode.type === "Literal" && typeof argNode.value === "string") {
|
|
278
|
+
literalArgs.push(argNode.value);
|
|
279
|
+
} else if (
|
|
280
|
+
argNode.type === "Literal" &&
|
|
281
|
+
(typeof argNode.value === "number" || typeof argNode.value === "boolean")
|
|
282
|
+
) {
|
|
283
|
+
literalArgs.push(argNode.value);
|
|
284
|
+
} else if (argNode.type === "Literal" && argNode.regex) {
|
|
285
|
+
// Regex literal — allowed for `match`
|
|
286
|
+
literalArgs.push(argNode.raw);
|
|
287
|
+
} else if (
|
|
288
|
+
argNode.type === "TemplateLiteral" &&
|
|
289
|
+
argNode.expressions.length === 0
|
|
290
|
+
) {
|
|
291
|
+
// Static template literal like `foo`
|
|
292
|
+
literalArgs.push(argNode.quasis[0].value.cooked);
|
|
293
|
+
} else {
|
|
294
|
+
// Non-literal argument → can't auto-fix
|
|
295
|
+
return {
|
|
296
|
+
...base,
|
|
297
|
+
literalArgs,
|
|
298
|
+
dependentKeys: [],
|
|
299
|
+
allLocal: false,
|
|
300
|
+
canAutoFix: false,
|
|
301
|
+
messageId: "cannotAutoFixDynamic",
|
|
302
|
+
reportData: { name: macroName },
|
|
303
|
+
};
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// Build transform args
|
|
308
|
+
const transformArgs = { literalArgs, argNodes, propName, sourceCode };
|
|
309
|
+
|
|
310
|
+
// Compute dependent keys
|
|
311
|
+
const dependentKeys = transform.toDependentKeys(transformArgs);
|
|
312
|
+
|
|
313
|
+
// Check for self-referencing getter
|
|
314
|
+
const depPaths = dependentKeys.map((k) => k.split(".")[0]);
|
|
315
|
+
if (depPaths.includes(propName)) {
|
|
316
|
+
return {
|
|
317
|
+
...base,
|
|
318
|
+
literalArgs,
|
|
319
|
+
dependentKeys,
|
|
320
|
+
allLocal: false,
|
|
321
|
+
canAutoFix: false,
|
|
322
|
+
messageId: "cannotAutoFixSelfReference",
|
|
323
|
+
reportData: { name: macroName, propName },
|
|
324
|
+
};
|
|
325
|
+
}
|
|
326
|
+
|
|
327
|
+
const allLocal = dependentKeys.every(isLocalKey);
|
|
328
|
+
|
|
329
|
+
// Determine which local deps need @tracked
|
|
330
|
+
let trackedDeps;
|
|
331
|
+
let existingNodesToDecorate;
|
|
332
|
+
if (allLocal) {
|
|
333
|
+
const info = findDepsNeedingTracked(propertyNode, dependentKeys);
|
|
334
|
+
trackedDeps = info.depsToInsert;
|
|
335
|
+
existingNodesToDecorate = info.existingNodesToDecorate;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
return {
|
|
339
|
+
...base,
|
|
340
|
+
literalArgs,
|
|
341
|
+
dependentKeys,
|
|
342
|
+
allLocal,
|
|
343
|
+
canAutoFix: true,
|
|
344
|
+
trackedDeps,
|
|
345
|
+
existingNodesToDecorate,
|
|
346
|
+
};
|
|
347
|
+
}
|
|
348
|
+
|
|
349
|
+
// ---------------------------------------------------------------------------
|
|
350
|
+
// Tracked dep post-processing
|
|
351
|
+
// ---------------------------------------------------------------------------
|
|
352
|
+
|
|
353
|
+
/**
|
|
354
|
+
* Remove tracked deps and nodes-to-decorate that correspond to other macro
|
|
355
|
+
* usages being converted to getters in the same class. After conversion,
|
|
356
|
+
* those properties will be getters (reactive by nature), so adding `@tracked`
|
|
357
|
+
* would create a duplicate class member.
|
|
358
|
+
*
|
|
359
|
+
* @param {MacroUsage[]} usages
|
|
360
|
+
*/
|
|
361
|
+
function excludeDepsBeingConverted(usages) {
|
|
362
|
+
// Group fixable usage prop names and property nodes by ClassBody
|
|
363
|
+
const convertedByClass = new Map();
|
|
364
|
+
for (const usage of usages) {
|
|
365
|
+
if (!usage.canAutoFix) {
|
|
366
|
+
continue;
|
|
367
|
+
}
|
|
368
|
+
const classBody = usage.propertyNode.parent;
|
|
369
|
+
if (!convertedByClass.has(classBody)) {
|
|
370
|
+
convertedByClass.set(classBody, { names: new Set(), nodes: new Set() });
|
|
371
|
+
}
|
|
372
|
+
const entry = convertedByClass.get(classBody);
|
|
373
|
+
entry.names.add(usage.propName);
|
|
374
|
+
entry.nodes.add(usage.propertyNode);
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
for (const usage of usages) {
|
|
378
|
+
const classBody = usage.propertyNode?.parent;
|
|
379
|
+
const converted = convertedByClass.get(classBody);
|
|
380
|
+
if (!converted) {
|
|
381
|
+
continue;
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
if (usage.trackedDeps) {
|
|
385
|
+
usage.trackedDeps = usage.trackedDeps.filter(
|
|
386
|
+
(dep) => !converted.names.has(dep)
|
|
387
|
+
);
|
|
388
|
+
}
|
|
389
|
+
if (usage.existingNodesToDecorate) {
|
|
390
|
+
usage.existingNodesToDecorate = usage.existingNodesToDecorate.filter(
|
|
391
|
+
(node) => !converted.nodes.has(node)
|
|
392
|
+
);
|
|
393
|
+
}
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
|
|
397
|
+
// ---------------------------------------------------------------------------
|
|
398
|
+
// Implicit injection exclusion
|
|
399
|
+
// ---------------------------------------------------------------------------
|
|
400
|
+
|
|
401
|
+
// Property names implicitly injected into Ember framework classes by
|
|
402
|
+
// Discourse's registerDiscourseImplicitInjections() (see
|
|
403
|
+
// discourse/app/lib/implicit-injections.js). Adding @tracked for an
|
|
404
|
+
// undeclared property with one of these names would shadow the inherited
|
|
405
|
+
// injection with undefined.
|
|
406
|
+
const IMPLICIT_INJECTION_NAMES = new Set([
|
|
407
|
+
// commonInjections (Controller, Component, Route, RestModel, RestAdapter)
|
|
408
|
+
"appEvents",
|
|
409
|
+
"pmTopicTrackingState",
|
|
410
|
+
"store",
|
|
411
|
+
"site",
|
|
412
|
+
"searchService",
|
|
413
|
+
"session",
|
|
414
|
+
"messageBus",
|
|
415
|
+
"siteSettings",
|
|
416
|
+
"topicTrackingState",
|
|
417
|
+
"keyValueStore",
|
|
418
|
+
// Controller, Component, Route extras
|
|
419
|
+
"capabilities",
|
|
420
|
+
"currentUser",
|
|
421
|
+
]);
|
|
422
|
+
|
|
423
|
+
/**
|
|
424
|
+
* Promote usages to `@computed` when any of their tracked deps (new
|
|
425
|
+
* declarations, not existing members) match a known implicitly-injected
|
|
426
|
+
* property name. Inserting `@tracked currentUser;` in a controller that
|
|
427
|
+
* inherits `currentUser` via implicit injection would shadow the injection
|
|
428
|
+
* with `undefined`.
|
|
429
|
+
*
|
|
430
|
+
* @param {MacroUsage[]} usages
|
|
431
|
+
*/
|
|
432
|
+
function excludeImplicitInjectionDeps(usages) {
|
|
433
|
+
for (const usage of usages) {
|
|
434
|
+
if (!usage.canAutoFix || !usage.allLocal || !usage.trackedDeps) {
|
|
435
|
+
continue;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
const hasImplicitDep = usage.trackedDeps.some((dep) =>
|
|
439
|
+
IMPLICIT_INJECTION_NAMES.has(dep)
|
|
440
|
+
);
|
|
441
|
+
|
|
442
|
+
if (hasImplicitDep) {
|
|
443
|
+
usage.allLocal = false;
|
|
444
|
+
usage.trackedDeps = undefined;
|
|
445
|
+
usage.existingNodesToDecorate = undefined;
|
|
446
|
+
}
|
|
447
|
+
}
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
// ---------------------------------------------------------------------------
|
|
451
|
+
// Classic component detection
|
|
452
|
+
// ---------------------------------------------------------------------------
|
|
453
|
+
|
|
454
|
+
// Decorators from @ember-decorators/component that are exclusively used on
|
|
455
|
+
// classic Ember components (those extending @ember/component).
|
|
456
|
+
const CLASSIC_COMPONENT_DECORATORS = new Set([
|
|
457
|
+
"classNames",
|
|
458
|
+
"classNameBindings",
|
|
459
|
+
"tagName",
|
|
460
|
+
"attributeBindings",
|
|
461
|
+
]);
|
|
462
|
+
|
|
463
|
+
/**
|
|
464
|
+
* Determine whether a class declaration represents a classic Ember component.
|
|
465
|
+
*
|
|
466
|
+
* Classic components use Ember's two-way binding system and cannot use
|
|
467
|
+
* `@tracked` + `@dependentKeyCompat` — they must use `@computed` instead.
|
|
468
|
+
*
|
|
469
|
+
* Detection signals:
|
|
470
|
+
* 1. Direct: superclass is `Component` imported from `@ember/component`
|
|
471
|
+
* 2. Decorator: class has decorators from `@ember-decorators/component`
|
|
472
|
+
* 3. Naming: superclass name ends with "Component" (and is NOT from `@glimmer/component`)
|
|
473
|
+
*
|
|
474
|
+
* @param {import('estree').Node} classNode - ClassDeclaration or ClassExpression
|
|
475
|
+
* @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} importsMap
|
|
476
|
+
* @returns {boolean}
|
|
477
|
+
*/
|
|
478
|
+
function isClassicComponent(classNode, importsMap) {
|
|
479
|
+
const superClass = classNode.superClass;
|
|
480
|
+
if (!superClass) {
|
|
481
|
+
return false;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
// 1. Direct: extends Component from @ember/component
|
|
485
|
+
if (superClass.type === "Identifier") {
|
|
486
|
+
const componentImport = importsMap.get("@ember/component");
|
|
487
|
+
if (componentImport) {
|
|
488
|
+
const defaultSpec = componentImport.specifiers.find(
|
|
489
|
+
(s) => s.type === "ImportDefaultSpecifier"
|
|
490
|
+
);
|
|
491
|
+
if (defaultSpec && defaultSpec.local.name === superClass.name) {
|
|
492
|
+
return true;
|
|
493
|
+
}
|
|
494
|
+
}
|
|
495
|
+
|
|
496
|
+
// Explicit exclusion: @glimmer/component is NOT classic
|
|
497
|
+
const glimmerImport = importsMap.get("@glimmer/component");
|
|
498
|
+
if (glimmerImport) {
|
|
499
|
+
const defaultSpec = glimmerImport.specifiers.find(
|
|
500
|
+
(s) => s.type === "ImportDefaultSpecifier"
|
|
501
|
+
);
|
|
502
|
+
if (defaultSpec && defaultSpec.local.name === superClass.name) {
|
|
503
|
+
return false;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
// 2. Decorator signal: @classNames, @tagName, etc. from @ember-decorators/component
|
|
509
|
+
if (classNode.decorators?.length > 0) {
|
|
510
|
+
const emberDecImport = importsMap.get("@ember-decorators/component");
|
|
511
|
+
if (emberDecImport) {
|
|
512
|
+
const importedNames = new Set(
|
|
513
|
+
emberDecImport.specifiers
|
|
514
|
+
.filter(
|
|
515
|
+
(s) =>
|
|
516
|
+
s.type === "ImportSpecifier" &&
|
|
517
|
+
CLASSIC_COMPONENT_DECORATORS.has(s.imported.name)
|
|
518
|
+
)
|
|
519
|
+
.map((s) => s.local.name)
|
|
520
|
+
);
|
|
521
|
+
const hasClassicDecorator = classNode.decorators.some((d) => {
|
|
522
|
+
const expr = d.expression;
|
|
523
|
+
const name =
|
|
524
|
+
expr.type === "Identifier"
|
|
525
|
+
? expr.name
|
|
526
|
+
: expr.type === "CallExpression" &&
|
|
527
|
+
expr.callee?.type === "Identifier"
|
|
528
|
+
? expr.callee.name
|
|
529
|
+
: null;
|
|
530
|
+
return name && importedNames.has(name);
|
|
531
|
+
});
|
|
532
|
+
if (hasClassicDecorator) {
|
|
533
|
+
return true;
|
|
534
|
+
}
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
|
|
538
|
+
// 3. Naming convention: superclass name ends with "Component"
|
|
539
|
+
const superName =
|
|
540
|
+
superClass.type === "Identifier"
|
|
541
|
+
? superClass.name
|
|
542
|
+
: superClass.type === "MemberExpression"
|
|
543
|
+
? superClass.property?.name
|
|
544
|
+
: null;
|
|
545
|
+
if (superName?.endsWith("Component")) {
|
|
546
|
+
return true;
|
|
547
|
+
}
|
|
548
|
+
|
|
549
|
+
return false;
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
/**
|
|
553
|
+
* Force `@computed` (instead of `@dependentKeyCompat` + `@tracked`) for all
|
|
554
|
+
* fixable macros inside classic Ember components.
|
|
555
|
+
*
|
|
556
|
+
* Classic components rely on Ember's two-way binding system. Adding `@tracked`
|
|
557
|
+
* bypasses classic property notifications and breaks template bindings. Using
|
|
558
|
+
* `@computed` keeps everything within the classic property system.
|
|
559
|
+
*
|
|
560
|
+
* Must run before `propagateComputedRequirement` so that forced-computed macros
|
|
561
|
+
* seed the transitive propagation.
|
|
562
|
+
*
|
|
563
|
+
* @param {MacroUsage[]} usages
|
|
564
|
+
* @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} importsMap
|
|
565
|
+
*/
|
|
566
|
+
function forceComputedForClassicComponents(usages, importsMap) {
|
|
567
|
+
for (const usage of usages) {
|
|
568
|
+
if (!usage.canAutoFix || !usage.allLocal) {
|
|
569
|
+
continue;
|
|
570
|
+
}
|
|
571
|
+
|
|
572
|
+
const classBody = usage.propertyNode.parent;
|
|
573
|
+
const classNode = classBody.parent;
|
|
574
|
+
if (isClassicComponent(classNode, importsMap)) {
|
|
575
|
+
usage.allLocal = false;
|
|
576
|
+
usage.trackedDeps = undefined;
|
|
577
|
+
usage.existingNodesToDecorate = undefined;
|
|
578
|
+
}
|
|
579
|
+
}
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
// ---------------------------------------------------------------------------
|
|
583
|
+
// Undeclared dep exclusion for subclasses
|
|
584
|
+
// ---------------------------------------------------------------------------
|
|
585
|
+
|
|
586
|
+
/**
|
|
587
|
+
* Promote usages to `@computed` when a class extends an unknown (non-framework)
|
|
588
|
+
* superclass and the fixer would insert NEW `@tracked` declarations for deps
|
|
589
|
+
* not found in the current class body.
|
|
590
|
+
*
|
|
591
|
+
* Without cross-file analysis we cannot know whether an undeclared dep is
|
|
592
|
+
* genuinely new local state or an inherited property (computed getter,
|
|
593
|
+
* injection, etc.) from the parent class. Inserting `@tracked propName;`
|
|
594
|
+
* for an inherited property shadows it with `undefined`, breaking runtime
|
|
595
|
+
* behavior. Promoting to `@computed` uses Ember's string-based observation
|
|
596
|
+
* which works correctly through the prototype chain.
|
|
597
|
+
*
|
|
598
|
+
* Classes extending known Ember/Glimmer framework base classes (`@ember/*`,
|
|
599
|
+
* `@glimmer/*`, `ember-data/*`) are safe — their APIs are well-documented and
|
|
600
|
+
* the `IMPLICIT_INJECTION_NAMES` exclusion already handles their dangerous
|
|
601
|
+
* properties.
|
|
602
|
+
*
|
|
603
|
+
* Deps that ARE declared in the current class body (`existingNodesToDecorate`)
|
|
604
|
+
* are safe — the property is visibly local, so there is no shadowing risk.
|
|
605
|
+
*
|
|
606
|
+
* Must run after `forceComputedForClassicComponents` (so classic component
|
|
607
|
+
* usages already have `trackedDeps` cleared) and before
|
|
608
|
+
* `propagateComputedRequirement`.
|
|
609
|
+
*
|
|
610
|
+
* @param {MacroUsage[]} usages
|
|
611
|
+
* @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} importsMap
|
|
612
|
+
*/
|
|
613
|
+
function excludeUndeclaredDepsInSubclasses(usages, importsMap) {
|
|
614
|
+
for (const usage of usages) {
|
|
615
|
+
if (!usage.canAutoFix || !usage.allLocal || !usage.trackedDeps?.length) {
|
|
616
|
+
continue;
|
|
617
|
+
}
|
|
618
|
+
|
|
619
|
+
const classNode = usage.propertyNode.parent.parent;
|
|
620
|
+
if (!classNode.superClass) {
|
|
621
|
+
continue;
|
|
622
|
+
}
|
|
623
|
+
|
|
624
|
+
// Known Ember/Glimmer framework classes have well-documented APIs.
|
|
625
|
+
// IMPLICIT_INJECTION_NAMES already handles their dangerous properties.
|
|
626
|
+
if (isKnownFrameworkSuperclass(classNode, importsMap)) {
|
|
627
|
+
continue;
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
// Unknown superclass — could define computed getters or other
|
|
631
|
+
// properties we'd shadow. Promote to @computed for safety.
|
|
632
|
+
usage.allLocal = false;
|
|
633
|
+
usage.trackedDeps = undefined;
|
|
634
|
+
usage.existingNodesToDecorate = undefined;
|
|
635
|
+
}
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
/**
|
|
639
|
+
* Check whether a class's superclass is a default import from a known
|
|
640
|
+
* Ember/Glimmer framework package (`@ember/*`, `@glimmer/*`, `ember-data/*`).
|
|
641
|
+
*
|
|
642
|
+
* @param {import('estree').Node} classNode - ClassDeclaration or ClassExpression
|
|
643
|
+
* @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} importsMap
|
|
644
|
+
* @returns {boolean}
|
|
645
|
+
*/
|
|
646
|
+
function isKnownFrameworkSuperclass(classNode, importsMap) {
|
|
647
|
+
const superClass = classNode.superClass;
|
|
648
|
+
if (!superClass || superClass.type !== "Identifier") {
|
|
649
|
+
return false;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
for (const [source, importInfo] of importsMap) {
|
|
653
|
+
if (
|
|
654
|
+
!source.startsWith("@ember/") &&
|
|
655
|
+
!source.startsWith("@glimmer/") &&
|
|
656
|
+
!source.startsWith("ember-data")
|
|
657
|
+
) {
|
|
658
|
+
continue;
|
|
659
|
+
}
|
|
660
|
+
|
|
661
|
+
const defaultSpec = importInfo.specifiers.find(
|
|
662
|
+
(s) => s.type === "ImportDefaultSpecifier"
|
|
663
|
+
);
|
|
664
|
+
if (defaultSpec && defaultSpec.local.name === superClass.name) {
|
|
665
|
+
return true;
|
|
666
|
+
}
|
|
667
|
+
}
|
|
668
|
+
|
|
669
|
+
return false;
|
|
670
|
+
}
|
|
671
|
+
|
|
672
|
+
/**
|
|
673
|
+
* Propagate the @computed requirement transitively through macro dependencies.
|
|
674
|
+
*
|
|
675
|
+
* A `@dependentKeyCompat` getter cannot observe a `@computed` getter. So when
|
|
676
|
+
* a fixable macro depends on a `@computed` getter — either another macro being
|
|
677
|
+
* converted or an *existing* `@computed` getter already in the class — the macro
|
|
678
|
+
* must also use `@computed`. This propagation is transitive.
|
|
679
|
+
*
|
|
680
|
+
* For each promoted macro we clear `trackedDeps` and `existingNodesToDecorate`
|
|
681
|
+
* because `@computed` getters don't require `@tracked` on their deps.
|
|
682
|
+
*
|
|
683
|
+
* @param {MacroUsage[]} usages
|
|
684
|
+
*/
|
|
685
|
+
function propagateComputedRequirement(usages) {
|
|
686
|
+
const classBuckets = new Map();
|
|
687
|
+
for (const usage of usages) {
|
|
688
|
+
if (!usage.canAutoFix) {
|
|
689
|
+
continue;
|
|
690
|
+
}
|
|
691
|
+
const classBody = usage.propertyNode.parent;
|
|
692
|
+
if (!classBuckets.has(classBody)) {
|
|
693
|
+
classBuckets.set(classBody, []);
|
|
694
|
+
}
|
|
695
|
+
classBuckets.get(classBody).push(usage);
|
|
696
|
+
}
|
|
697
|
+
|
|
698
|
+
for (const classUsages of classBuckets.values()) {
|
|
699
|
+
const classBody = classUsages[0].propertyNode.parent;
|
|
700
|
+
|
|
701
|
+
// Seed from macros being converted that already have nested deps
|
|
702
|
+
const computedPropNames = new Set();
|
|
703
|
+
for (const usage of classUsages) {
|
|
704
|
+
if (!usage.allLocal) {
|
|
705
|
+
computedPropNames.add(usage.propName);
|
|
706
|
+
}
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
// Seed from existing @computed getters already in the class
|
|
710
|
+
for (const member of classBody.body) {
|
|
711
|
+
if (member.type === "MethodDefinition" && hasComputedDecorator(member)) {
|
|
712
|
+
const name =
|
|
713
|
+
member.key.type === "Identifier"
|
|
714
|
+
? member.key.name
|
|
715
|
+
: String(member.key.value);
|
|
716
|
+
computedPropNames.add(name);
|
|
717
|
+
}
|
|
718
|
+
}
|
|
719
|
+
|
|
720
|
+
if (computedPropNames.size === 0) {
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
|
|
724
|
+
// Fixed-point loop: keep propagating until no new promotions occur
|
|
725
|
+
let changed = true;
|
|
726
|
+
while (changed) {
|
|
727
|
+
changed = false;
|
|
728
|
+
for (const usage of classUsages) {
|
|
729
|
+
if (!usage.allLocal) {
|
|
730
|
+
continue;
|
|
731
|
+
}
|
|
732
|
+
if (usage.dependentKeys.some((k) => computedPropNames.has(k))) {
|
|
733
|
+
usage.allLocal = false;
|
|
734
|
+
usage.trackedDeps = undefined;
|
|
735
|
+
usage.existingNodesToDecorate = undefined;
|
|
736
|
+
computedPropNames.add(usage.propName);
|
|
737
|
+
changed = true;
|
|
738
|
+
}
|
|
739
|
+
}
|
|
740
|
+
}
|
|
741
|
+
}
|
|
742
|
+
}
|
|
743
|
+
|
|
744
|
+
/**
|
|
745
|
+
* Check whether a class member has a `@computed` decorator.
|
|
746
|
+
*
|
|
747
|
+
* @param {import('estree').Node} member
|
|
748
|
+
* @returns {boolean}
|
|
749
|
+
*/
|
|
750
|
+
function hasComputedDecorator(member) {
|
|
751
|
+
return (
|
|
752
|
+
member.decorators?.some((d) => {
|
|
753
|
+
const expr = d.expression;
|
|
754
|
+
return (
|
|
755
|
+
(expr.type === "Identifier" && expr.name === "computed") ||
|
|
756
|
+
(expr.type === "CallExpression" &&
|
|
757
|
+
expr.callee?.type === "Identifier" &&
|
|
758
|
+
expr.callee.name === "computed")
|
|
759
|
+
);
|
|
760
|
+
}) ?? false
|
|
761
|
+
);
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
/**
|
|
765
|
+
* Ensure each new `@tracked` dep name is assigned to at most ONE usage.
|
|
766
|
+
* Without this, two macros referencing the same dep would both try to
|
|
767
|
+
* insert `@tracked propName;` (since new declarations are prepended to
|
|
768
|
+
* each property replacement to avoid range overlaps).
|
|
769
|
+
*
|
|
770
|
+
* Note: `existingNodesToDecorate` is NOT deduplicated here — those are
|
|
771
|
+
* aggregated centrally in the import-level fix via a Set.
|
|
772
|
+
*
|
|
773
|
+
* @param {MacroUsage[]} usages
|
|
774
|
+
*/
|
|
775
|
+
function deduplicateTrackedDeps(usages) {
|
|
776
|
+
const claimedDeps = new Set();
|
|
777
|
+
|
|
778
|
+
for (const usage of usages) {
|
|
779
|
+
if (usage.trackedDeps) {
|
|
780
|
+
usage.trackedDeps = usage.trackedDeps.filter((dep) => {
|
|
781
|
+
if (claimedDeps.has(dep)) {
|
|
782
|
+
return false;
|
|
783
|
+
}
|
|
784
|
+
claimedDeps.add(dep);
|
|
785
|
+
return true;
|
|
786
|
+
});
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
}
|
|
790
|
+
|
|
791
|
+
// ---------------------------------------------------------------------------
|
|
792
|
+
// @tracked dependency detection
|
|
793
|
+
// ---------------------------------------------------------------------------
|
|
794
|
+
|
|
795
|
+
/**
|
|
796
|
+
* @typedef {Object} TrackedDepsInfo
|
|
797
|
+
* @property {string[]} depsToInsert - Deps that need a NEW `@tracked propName;` declaration
|
|
798
|
+
* @property {import('estree').Node[]} existingNodesToDecorate - Existing PropertyDefinition
|
|
799
|
+
* nodes that need `@tracked` added as a decorator
|
|
800
|
+
*/
|
|
801
|
+
|
|
802
|
+
/**
|
|
803
|
+
* Given a PropertyDefinition inside a class body, determine which local
|
|
804
|
+
* dependent keys need `@tracked` handling:
|
|
805
|
+
*
|
|
806
|
+
* - Keys matching a MethodDefinition (getter/method) → already reactive, skip
|
|
807
|
+
* - Keys declared as PropertyDefinition with any decorator → already managed
|
|
808
|
+
* by something (@service, @tracked, @inject, etc.), skip
|
|
809
|
+
* - Keys declared as PropertyDefinition without decorators → need `@tracked` added
|
|
810
|
+
* - Keys not declared as any class member → need a new `@tracked propName;` inserted
|
|
811
|
+
*
|
|
812
|
+
* @param {import('estree').Node} propertyNode - The PropertyDefinition node
|
|
813
|
+
* @param {string[]} dependentKeys - Local-only dependent keys (no dots)
|
|
814
|
+
* @returns {TrackedDepsInfo}
|
|
815
|
+
*/
|
|
816
|
+
function findDepsNeedingTracked(propertyNode, dependentKeys) {
|
|
817
|
+
const classBody = propertyNode.parent;
|
|
818
|
+
if (!classBody || classBody.type !== "ClassBody") {
|
|
819
|
+
return { depsToInsert: [...dependentKeys], existingNodesToDecorate: [] };
|
|
820
|
+
}
|
|
821
|
+
|
|
822
|
+
const reactiveMembers = new Set(); // getters, methods, and decorated properties
|
|
823
|
+
const untrackedMemberNodes = new Map(); // name → AST node
|
|
824
|
+
|
|
825
|
+
for (const member of classBody.body) {
|
|
826
|
+
// Getters and methods are already reactive — no @tracked needed
|
|
827
|
+
if (member.type === "MethodDefinition") {
|
|
828
|
+
const name =
|
|
829
|
+
member.key.type === "Identifier"
|
|
830
|
+
? member.key.name
|
|
831
|
+
: String(member.key.value);
|
|
832
|
+
reactiveMembers.add(name);
|
|
833
|
+
continue;
|
|
834
|
+
}
|
|
835
|
+
|
|
836
|
+
if (member.type !== "PropertyDefinition") {
|
|
837
|
+
continue;
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
const name =
|
|
841
|
+
member.key.type === "Identifier"
|
|
842
|
+
? member.key.name
|
|
843
|
+
: String(member.key.value);
|
|
844
|
+
|
|
845
|
+
// Any decorated property is already managed by its decorator
|
|
846
|
+
// (@tracked, @service, @inject, etc.) — adding @tracked would be wrong.
|
|
847
|
+
if (member.decorators?.length > 0) {
|
|
848
|
+
reactiveMembers.add(name);
|
|
849
|
+
} else {
|
|
850
|
+
untrackedMemberNodes.set(name, member);
|
|
851
|
+
}
|
|
852
|
+
}
|
|
853
|
+
|
|
854
|
+
const depsToInsert = [];
|
|
855
|
+
const existingNodesToDecorate = [];
|
|
856
|
+
|
|
857
|
+
for (const key of dependentKeys) {
|
|
858
|
+
if (reactiveMembers.has(key)) {
|
|
859
|
+
continue; // already reactive (getter, method, or decorated property)
|
|
860
|
+
}
|
|
861
|
+
if (untrackedMemberNodes.has(key)) {
|
|
862
|
+
existingNodesToDecorate.push(untrackedMemberNodes.get(key));
|
|
863
|
+
} else {
|
|
864
|
+
depsToInsert.push(key);
|
|
865
|
+
}
|
|
866
|
+
}
|
|
867
|
+
|
|
868
|
+
return { depsToInsert, existingNodesToDecorate };
|
|
869
|
+
}
|
|
870
|
+
|
|
871
|
+
// ---------------------------------------------------------------------------
|
|
872
|
+
// AST walker
|
|
873
|
+
// ---------------------------------------------------------------------------
|
|
874
|
+
|
|
875
|
+
/**
|
|
876
|
+
* Simple recursive AST walker that calls `visitor(node)` for every node.
|
|
877
|
+
*
|
|
878
|
+
* @param {import('estree').Node} node
|
|
879
|
+
* @param {(node: import('estree').Node) => void} visitor
|
|
880
|
+
*/
|
|
881
|
+
function walkNode(node, visitor) {
|
|
882
|
+
if (!node || typeof node !== "object") {
|
|
883
|
+
return;
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
visitor(node);
|
|
887
|
+
|
|
888
|
+
for (const key in node) {
|
|
889
|
+
if (key === "parent" || key === "range" || key === "loc") {
|
|
890
|
+
continue;
|
|
891
|
+
}
|
|
892
|
+
const child = node[key];
|
|
893
|
+
if (Array.isArray(child)) {
|
|
894
|
+
for (const item of child) {
|
|
895
|
+
if (item && typeof item.type === "string") {
|
|
896
|
+
walkNode(item, visitor);
|
|
897
|
+
}
|
|
898
|
+
}
|
|
899
|
+
} else if (child && typeof child.type === "string") {
|
|
900
|
+
walkNode(child, visitor);
|
|
901
|
+
}
|
|
902
|
+
}
|
|
903
|
+
}
|