@discourse/lint-configs 2.42.0 → 2.44.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.
@@ -11,7 +11,7 @@ export default {
11
11
  create(context) {
12
12
  return {
13
13
  ImportDeclaration(node) {
14
- function denyImporting(symbolName, messageTemplate) {
14
+ function denyImporting(symbolName, messageTemplate, fixFn) {
15
15
  const specifier = node.specifiers.find(
16
16
  (spec) => spec.imported && spec.imported.name === symbolName
17
17
  );
@@ -20,6 +20,7 @@ export default {
20
20
  context.report({
21
21
  node: specifier,
22
22
  message: messageTemplate(symbolName),
23
+ fix: fixFn ? (fixer) => fixFn(fixer, specifier) : undefined,
23
24
  });
24
25
  }
25
26
  }
@@ -69,7 +70,7 @@ export default {
69
70
  fix(fixer) {
70
71
  return fixer.replaceText(
71
72
  node,
72
- `import { htmlSafe } from "@ember/template";`
73
+ `import { trustHTML } from "@ember/template";`
73
74
  );
74
75
  },
75
76
  });
@@ -88,6 +89,38 @@ export default {
88
89
  );
89
90
  },
90
91
  });
92
+ } else if (node.source.value === "@ember/template") {
93
+ denyImporting(
94
+ "htmlSafe",
95
+ () =>
96
+ "'htmlSafe' is deprecated. Use 'trustHTML' from '@ember/template' instead.",
97
+ (fixer, specifier) => {
98
+ const fixes = [
99
+ fixer.replaceText(specifier.imported, "trustHTML"),
100
+ ];
101
+
102
+ if (specifier.local.name === specifier.imported.name) {
103
+ const moduleScope = context.sourceCode.scopeManager.scopes.find(
104
+ (s) => s.type === "module"
105
+ );
106
+ const variable = moduleScope?.variables.find(
107
+ (v) => v.name === specifier.local.name
108
+ );
109
+
110
+ if (variable) {
111
+ for (const ref of variable.references) {
112
+ if (ref.identifier !== specifier.local) {
113
+ fixes.push(
114
+ fixer.replaceText(ref.identifier, "trustHTML")
115
+ );
116
+ }
117
+ }
118
+ }
119
+ }
120
+
121
+ return fixes;
122
+ }
123
+ );
91
124
  } else if (node.source.value === "@ember/array") {
92
125
  const messageTemplate = (symbolName) =>
93
126
  `Importing '${symbolName}' from '@ember/array' is deprecated. Use tracked arrays or native JavaScript arrays instead.`;
@@ -107,6 +140,38 @@ export default {
107
140
  denyDefaultImport(
108
141
  "Importing ArrayProxy (default) from '@ember/array/proxy' is deprecated. Use tracked arrays or native JavaScript arrays instead."
109
142
  );
143
+ } else if (node.source.value === "discourse/lib/tracked-tools") {
144
+ denyImporting(
145
+ "trackedArray",
146
+ () =>
147
+ "'trackedArray' is deprecated. Use 'autoTrackedArray' from 'discourse/lib/tracked-tools' instead.",
148
+ (fixer, specifier) => {
149
+ const fixes = [
150
+ fixer.replaceText(specifier.imported, "autoTrackedArray"),
151
+ ];
152
+
153
+ if (specifier.local.name === specifier.imported.name) {
154
+ const moduleScope = context.sourceCode.scopeManager.scopes.find(
155
+ (s) => s.type === "module"
156
+ );
157
+ const variable = moduleScope?.variables.find(
158
+ (v) => v.name === specifier.local.name
159
+ );
160
+
161
+ if (variable) {
162
+ for (const ref of variable.references) {
163
+ if (ref.identifier !== specifier.local) {
164
+ fixes.push(
165
+ fixer.replaceText(ref.identifier, "autoTrackedArray")
166
+ );
167
+ }
168
+ }
169
+ }
170
+ }
171
+
172
+ return fixes;
173
+ }
174
+ );
110
175
  }
111
176
  },
112
177
  };
@@ -0,0 +1,395 @@
1
+ import { collectImports } from "./utils/analyze-imports.mjs";
2
+ import { fixImport } from "./utils/fix-import.mjs";
3
+
4
+ const SPECIFIER_MAPPING = {
5
+ TrackedArray: "trackedArray",
6
+ TrackedObject: "trackedObject",
7
+ TrackedMap: "trackedMap",
8
+ TrackedSet: "trackedSet",
9
+ TrackedWeakMap: "trackedWeakMap",
10
+ TrackedWeakSet: "trackedWeakSet",
11
+ };
12
+
13
+ const OLD_SOURCES = new Set([
14
+ "@ember-compat/tracked-built-ins",
15
+ "tracked-built-ins",
16
+ ]);
17
+ const NEW_SOURCE = "@ember/reactive/collections";
18
+
19
+ function buildImportMessage(specifiersToTransform, oldSource) {
20
+ const oldNames = specifiersToTransform.map((s) => s.imported.name);
21
+ const newNames = oldNames.map((n) => SPECIFIER_MAPPING[n]);
22
+
23
+ const oldList = oldNames.map((n) => `'${n}'`).join(", ");
24
+ const newList = newNames.map((n) => `'${n}'`).join(", ");
25
+
26
+ const usageNotes = specifiersToTransform
27
+ .map((s) => {
28
+ const newName = SPECIFIER_MAPPING[s.imported.name];
29
+ const localName = s.local.name;
30
+ const callName = localName === s.imported.name ? newName : localName;
31
+ return `${callName}() instead of new ${localName}()`;
32
+ })
33
+ .join(", ");
34
+
35
+ return (
36
+ `Use ${newList} from '${NEW_SOURCE}' instead of ${oldList} from '${oldSource}'.` +
37
+ ` Note: use ${usageNotes}.`
38
+ );
39
+ }
40
+
41
+ function buildNonNewMessage(specifier) {
42
+ const oldName = specifier.imported.name;
43
+ const newName = SPECIFIER_MAPPING[oldName];
44
+
45
+ return (
46
+ `'${oldName}' must be migrated to '${NEW_SOURCE}', but this usage requires manual review.` +
47
+ ` The new module exports '${newName}' as a factory function, not a class,` +
48
+ ` so 'instanceof', class references, etc. will not work the same way.`
49
+ );
50
+ }
51
+
52
+ function buildNamingConflictMessage(specifier) {
53
+ const oldName = specifier.imported.name;
54
+ const newName = SPECIFIER_MAPPING[oldName];
55
+
56
+ return (
57
+ `Use \`${newName}\` from '${NEW_SOURCE}' instead of \`${oldName}\`:` +
58
+ ` \`${newName}\` conflicts with an existing binding. Rename the conflicting identifier first.`
59
+ );
60
+ }
61
+
62
+ function isNewExpression(ref) {
63
+ const parent = ref.identifier.parent;
64
+ return parent.type === "NewExpression" && parent.callee === ref.identifier;
65
+ }
66
+
67
+ function buildNewSpecifier(specifier) {
68
+ const oldName = specifier.imported.name;
69
+ const localName = specifier.local.name;
70
+ const newName = SPECIFIER_MAPPING[oldName] || oldName;
71
+
72
+ if (localName === oldName) {
73
+ return newName;
74
+ }
75
+ return `${newName} as ${localName}`;
76
+ }
77
+
78
+ function buildOldSpecifier(specifier) {
79
+ const oldName = specifier.imported.name;
80
+ const localName = specifier.local.name;
81
+
82
+ if (localName === oldName) {
83
+ return oldName;
84
+ }
85
+ return `${oldName} as ${localName}`;
86
+ }
87
+
88
+ /**
89
+ * Checks whether the new function name for a specifier would conflict with
90
+ * an existing binding in the module scope (excluding the old import itself).
91
+ *
92
+ * @param {import('estree').ImportSpecifier} specifier
93
+ * @param {import('eslint').Scope.Scope} moduleScope
94
+ * @returns {boolean}
95
+ */
96
+ function hasNamingConflict(specifier, moduleScope) {
97
+ // Aliased imports won't introduce a new name — the alias stays the same
98
+ if (specifier.local.name !== specifier.imported.name) {
99
+ return false;
100
+ }
101
+
102
+ const newName = SPECIFIER_MAPPING[specifier.imported.name];
103
+ const variable = moduleScope?.variables.find((v) => v.name === newName);
104
+
105
+ // No variable with that name exists — no conflict
106
+ if (!variable) {
107
+ return false;
108
+ }
109
+
110
+ // If the variable's only definition is an import from one of the old sources,
111
+ // that's the import we're replacing — not a real conflict
112
+ const isFromOldImport = variable.defs.every(
113
+ (def) =>
114
+ def.type === "ImportBinding" && OLD_SOURCES.has(def.parent?.source?.value)
115
+ );
116
+
117
+ return !isFromOldImport;
118
+ }
119
+
120
+ /**
121
+ * Looks up the local name already used for a given new specifier name in the
122
+ * existing import from NEW_SOURCE. Returns the alias if found, or null.
123
+ *
124
+ * @param {string} newName - The new imported name (e.g. "trackedArray")
125
+ * @param {object|undefined} existingImportInfo - From collectImports
126
+ * @returns {string|null} The local alias, or null if not already imported
127
+ */
128
+ function getExistingLocalName(newName, existingImportInfo) {
129
+ if (!existingImportInfo) {
130
+ return null;
131
+ }
132
+
133
+ const spec = existingImportInfo.specifiers.find(
134
+ (s) => s.type === "ImportSpecifier" && s.imported.name === newName
135
+ );
136
+
137
+ return spec ? spec.local.name : null;
138
+ }
139
+
140
+ export default {
141
+ meta: {
142
+ type: "suggestion",
143
+ docs: {
144
+ description:
145
+ "Replace imports from '@ember-compat/tracked-built-ins' and 'tracked-built-ins'" +
146
+ ` with '${NEW_SOURCE}'`,
147
+ },
148
+ fixable: "code",
149
+ schema: [],
150
+ },
151
+
152
+ create(context) {
153
+ return {
154
+ ImportDeclaration(node) {
155
+ const oldSource = node.source.value;
156
+
157
+ if (!OLD_SOURCES.has(oldSource)) {
158
+ return;
159
+ }
160
+
161
+ const specifiersToTransform = node.specifiers.filter(
162
+ (s) =>
163
+ s.type === "ImportSpecifier" && SPECIFIER_MAPPING[s.imported.name]
164
+ );
165
+
166
+ // Report on `tracked` import separately — it's likely confused with
167
+ // @glimmer/tracking's `tracked` decorator. There's no auto-fix since
168
+ // the replacement depends on usage context.
169
+ const trackedSpecifier = node.specifiers.find(
170
+ (s) => s.type === "ImportSpecifier" && s.imported.name === "tracked"
171
+ );
172
+
173
+ if (trackedSpecifier) {
174
+ context.report({
175
+ node: trackedSpecifier,
176
+ message:
177
+ `'tracked' should not be imported from '${oldSource}'.` +
178
+ ` Use '@glimmer/tracking' for the @tracked decorator,` +
179
+ ` or use the specific factory functions from '${NEW_SOURCE}'` +
180
+ ` (e.g. trackedArray(), trackedMap(), trackedObject()).`,
181
+ });
182
+ }
183
+
184
+ if (specifiersToTransform.length === 0) {
185
+ return;
186
+ }
187
+
188
+ const moduleScope = context.sourceCode.scopeManager.scopes.find(
189
+ (s) => s.type === "module"
190
+ );
191
+
192
+ const imports = collectImports(context.sourceCode);
193
+ const existingNewSourceImport = imports.get(NEW_SOURCE);
194
+
195
+ // Classify each specifier as fixable or unfixable
196
+ const fixable = [];
197
+ const unfixable = [];
198
+ const nonNewRefs = [];
199
+ const namingConflicts = [];
200
+
201
+ for (const specifier of specifiersToTransform) {
202
+ const variable = moduleScope?.variables.find(
203
+ (v) => v.name === specifier.local.name
204
+ );
205
+
206
+ let specifierIsFixable = true;
207
+
208
+ if (variable) {
209
+ for (const ref of variable.references) {
210
+ if (!isNewExpression(ref)) {
211
+ specifierIsFixable = false;
212
+ nonNewRefs.push({ ref, specifier });
213
+ }
214
+ }
215
+ }
216
+
217
+ // Check naming conflicts only for specifiers that would otherwise
218
+ // be fixable and that don't already exist in the new source import
219
+ if (specifierIsFixable) {
220
+ const newName = SPECIFIER_MAPPING[specifier.imported.name];
221
+ const alreadyImported = getExistingLocalName(
222
+ newName,
223
+ existingNewSourceImport
224
+ );
225
+
226
+ if (!alreadyImported && hasNamingConflict(specifier, moduleScope)) {
227
+ specifierIsFixable = false;
228
+ namingConflicts.push(specifier);
229
+ }
230
+ }
231
+
232
+ if (specifierIsFixable) {
233
+ fixable.push(specifier);
234
+ } else {
235
+ unfixable.push(specifier);
236
+ }
237
+ }
238
+
239
+ const hasFix = fixable.length > 0;
240
+
241
+ // Specifiers not in SPECIFIER_MAPPING (e.g. `tracked`) that must stay
242
+ // on the old import if we split
243
+ const unmappedSpecifiers = node.specifiers.filter(
244
+ (s) =>
245
+ s.type === "ImportSpecifier" && !SPECIFIER_MAPPING[s.imported.name]
246
+ );
247
+
248
+ // Report on import node
249
+ context.report({
250
+ node,
251
+ message: buildImportMessage(specifiersToTransform, oldSource),
252
+ fix: hasFix
253
+ ? (fixer) => {
254
+ const fixes = [];
255
+ const keepOnOld = [
256
+ ...unfixable.map(buildOldSpecifier),
257
+ ...unmappedSpecifiers.map(buildOldSpecifier),
258
+ ];
259
+
260
+ if (existingNewSourceImport) {
261
+ // Merge into existing import from NEW_SOURCE
262
+ const specifiersToAdd = [];
263
+
264
+ for (const specifier of fixable) {
265
+ const newName = SPECIFIER_MAPPING[specifier.imported.name];
266
+ const alreadyImported = getExistingLocalName(
267
+ newName,
268
+ existingNewSourceImport
269
+ );
270
+
271
+ if (!alreadyImported) {
272
+ specifiersToAdd.push(buildNewSpecifier(specifier));
273
+ }
274
+ }
275
+
276
+ // Add new specifiers to the existing import
277
+ if (specifiersToAdd.length > 0) {
278
+ fixes.push(
279
+ fixImport(fixer, existingNewSourceImport.node, {
280
+ namedImportsToAdd: specifiersToAdd,
281
+ })
282
+ );
283
+ }
284
+
285
+ // Remove or trim the old import
286
+ if (keepOnOld.length === 0) {
287
+ fixes.push(fixer.remove(node));
288
+ } else {
289
+ fixes.push(
290
+ fixer.replaceText(
291
+ node,
292
+ `import { ${keepOnOld.join(", ")} } from "${oldSource}";`
293
+ )
294
+ );
295
+ }
296
+ } else if (keepOnOld.length === 0) {
297
+ // All fixable: replace entire import with new source
298
+ const newSpecifiers =
299
+ specifiersToTransform.map(buildNewSpecifier);
300
+
301
+ fixes.push(
302
+ fixer.replaceText(
303
+ node,
304
+ `import { ${newSpecifiers.join(", ")} } from "${NEW_SOURCE}";`
305
+ )
306
+ );
307
+ } else {
308
+ // Partial fix: split into two imports
309
+ const newSpecifiers = fixable.map(buildNewSpecifier);
310
+
311
+ fixes.push(
312
+ fixer.replaceText(
313
+ node,
314
+ `import { ${keepOnOld.join(", ")} } from "${oldSource}";\n` +
315
+ `import { ${newSpecifiers.join(", ")} } from "${NEW_SOURCE}";`
316
+ )
317
+ );
318
+ }
319
+
320
+ // Fix usage sites for fixable specifiers
321
+ for (const specifier of fixable) {
322
+ const localName = specifier.local.name;
323
+ const isAliased = localName !== specifier.imported.name;
324
+ const newFunctionName =
325
+ SPECIFIER_MAPPING[specifier.imported.name];
326
+
327
+ // Determine the name to use at call sites — if the new
328
+ // source already imports this with an alias, use that alias
329
+ const existingLocal = getExistingLocalName(
330
+ newFunctionName,
331
+ existingNewSourceImport
332
+ );
333
+ const callSiteName = existingLocal || newFunctionName;
334
+
335
+ const variable = moduleScope?.variables.find(
336
+ (v) => v.name === localName
337
+ );
338
+ if (!variable) {
339
+ continue;
340
+ }
341
+
342
+ for (const ref of variable.references) {
343
+ const parent = ref.identifier.parent;
344
+
345
+ if (
346
+ parent.type === "NewExpression" &&
347
+ parent.callee === ref.identifier
348
+ ) {
349
+ // Remove `new ` keyword
350
+ fixes.push(
351
+ fixer.removeRange([
352
+ parent.range[0],
353
+ parent.callee.range[0],
354
+ ])
355
+ );
356
+
357
+ // Rename identifier to the correct call-site name
358
+ if (existingLocal) {
359
+ // Use the alias from the existing import
360
+ fixes.push(
361
+ fixer.replaceText(ref.identifier, existingLocal)
362
+ );
363
+ } else if (!isAliased) {
364
+ fixes.push(
365
+ fixer.replaceText(ref.identifier, callSiteName)
366
+ );
367
+ }
368
+ }
369
+ }
370
+ }
371
+
372
+ return fixes;
373
+ }
374
+ : null,
375
+ });
376
+
377
+ // Report on each non-new reference
378
+ for (const { ref, specifier } of nonNewRefs) {
379
+ context.report({
380
+ node: ref.identifier,
381
+ message: buildNonNewMessage(specifier),
382
+ });
383
+ }
384
+
385
+ // Report on each naming conflict
386
+ for (const specifier of namingConflicts) {
387
+ context.report({
388
+ node: specifier,
389
+ message: buildNamingConflictMessage(specifier),
390
+ });
391
+ }
392
+ },
393
+ };
394
+ },
395
+ };
@@ -0,0 +1,242 @@
1
+ // key: the mutable argument name
2
+ // value array: component names
3
+ const MUTATING_COMPONENTS = {
4
+ "@value": [
5
+ "Input",
6
+ "Textarea",
7
+ "TextField",
8
+ "DatePicker",
9
+ "ChatChannelChooser",
10
+ ],
11
+ "@checked": ["Input", "PreferenceCheckbox"],
12
+ "@selection": ["RadioButton", "InstallThemeItem", "ChatToTopicSelector"],
13
+ "@postAction": ["AdminPenaltyPostAction"],
14
+ "@reason": ["AdminPenaltyReason"],
15
+ "@tags": ["TagChooser", "ChatToTopicSelector"],
16
+ "@capsLockOn": ["PasswordField"],
17
+ "@message": ["FlagActionType"],
18
+ "@isConfirmed": ["FlagActionType"],
19
+ "@topicTitle": ["ChatToTopicSelector"],
20
+ "@categoryId": ["ChatToTopicSelector"],
21
+ };
22
+
23
+ function getImportIdentifier(node, source, namedImportIdentifier = null) {
24
+ if (node.source.value !== source) {
25
+ return;
26
+ }
27
+
28
+ return node.specifiers
29
+ .filter((specifier) => {
30
+ return (
31
+ (specifier.type === "ImportSpecifier" &&
32
+ specifier.imported.name === namedImportIdentifier) ||
33
+ (!namedImportIdentifier && specifier.type === "ImportDefaultSpecifier")
34
+ );
35
+ })
36
+ .map((specifier) => specifier.local.name)
37
+ .pop();
38
+ }
39
+
40
+ function isComponentClass(node, componentNames) {
41
+ return (
42
+ node.superClass?.type === "Identifier" &&
43
+ componentNames.has(node.superClass.name)
44
+ );
45
+ }
46
+
47
+ function getAssignedPropertyName(node) {
48
+ if (
49
+ node?.type !== "MemberExpression" ||
50
+ node.object?.type !== "ThisExpression"
51
+ ) {
52
+ return;
53
+ }
54
+
55
+ if (!node.computed && node.property?.type === "Identifier") {
56
+ return node.property.name;
57
+ }
58
+
59
+ if (node.computed && node.property?.type === "Literal") {
60
+ return node.property.value;
61
+ }
62
+ }
63
+
64
+ function hasTrackedDecorator(node) {
65
+ return node.decorators.some((decorator) => {
66
+ return (
67
+ decorator.expression.type === "Identifier" &&
68
+ decorator.expression.name === "tracked"
69
+ );
70
+ });
71
+ }
72
+
73
+ export default {
74
+ meta: {
75
+ type: "suggestion",
76
+ docs: {
77
+ description:
78
+ "Component properties should be @tracked only when they're reassigned at some point.",
79
+ },
80
+ schema: [], // no options
81
+ },
82
+
83
+ create(context) {
84
+ const componentNames = new Set();
85
+ const selectKitComponents = new Set();
86
+ let currentComponent;
87
+
88
+ function markAssigned(name) {
89
+ if (currentComponent && name) {
90
+ currentComponent.assigned.add(name);
91
+ }
92
+ }
93
+
94
+ function handleTrackedProperty(node) {
95
+ if (!currentComponent || node.static || !node.decorators?.length) {
96
+ return;
97
+ }
98
+
99
+ if (!hasTrackedDecorator(node) || node.key?.type !== "Identifier") {
100
+ return;
101
+ }
102
+
103
+ currentComponent.trackedProps.set(node.key.name, node);
104
+ }
105
+
106
+ function handleGlimmerSubExpression(node) {
107
+ if (!currentComponent || !node.path?.head) {
108
+ return;
109
+ }
110
+
111
+ if (node.path.head.type !== "VarHead" || node.path.head.name !== "mut") {
112
+ return;
113
+ }
114
+
115
+ const firstParam = node.params?.[0];
116
+ if (firstParam.type !== "GlimmerPathExpression") {
117
+ return;
118
+ }
119
+
120
+ if (firstParam.head.type === "ThisHead" && firstParam.tail.length) {
121
+ currentComponent.mutUses.add(firstParam.tail[0]);
122
+ }
123
+ }
124
+
125
+ function handleGlimmerElementNode(node) {
126
+ if (!currentComponent) {
127
+ return;
128
+ }
129
+
130
+ const componentName = node.tag || node.name;
131
+ if (!componentName) {
132
+ return;
133
+ }
134
+
135
+ const attributes = node.attributes || [];
136
+ for (const attr of attributes) {
137
+ if (attr.type !== "GlimmerAttrNode") {
138
+ continue;
139
+ }
140
+
141
+ const isMutatingAttr =
142
+ MUTATING_COMPONENTS[attr.name]?.includes(componentName) ||
143
+ (attr.name === "@value" && selectKitComponents.has(componentName));
144
+ if (!isMutatingAttr) {
145
+ continue;
146
+ }
147
+
148
+ if (!attr.value || attr.value.type !== "GlimmerMustacheStatement") {
149
+ continue;
150
+ }
151
+
152
+ const path = attr.value.path;
153
+ if (
154
+ path?.type === "GlimmerPathExpression" &&
155
+ path.head?.type === "ThisHead" &&
156
+ path.tail?.length
157
+ ) {
158
+ currentComponent.valueUses.add(path.tail[0]);
159
+ }
160
+ }
161
+ }
162
+
163
+ function handleClass(node) {
164
+ if (isComponentClass(node, componentNames)) {
165
+ currentComponent = {
166
+ node,
167
+ trackedProps: new Map(),
168
+ assigned: new Set(),
169
+ mutUses: new Set(),
170
+ valueUses: new Set(),
171
+ };
172
+ }
173
+ }
174
+
175
+ function handleClassExit(node) {
176
+ if (currentComponent?.node !== node) {
177
+ return;
178
+ }
179
+
180
+ for (const [name, propNode] of currentComponent.trackedProps) {
181
+ const reassigned = currentComponent.assigned.has(name);
182
+ const hasMutUse = currentComponent.mutUses.has(name);
183
+ const hasValueUse = currentComponent.valueUses.has(name);
184
+
185
+ if (!reassigned && !hasMutUse && !hasValueUse) {
186
+ context.report({
187
+ node: propNode,
188
+ message: `\`${name}\` property is @tracked but isn't modified anywhere.`,
189
+ });
190
+ }
191
+ }
192
+
193
+ currentComponent = null;
194
+ }
195
+
196
+ return {
197
+ ImportDeclaration(node) {
198
+ if (node.source.value.includes("/select-kit/")) {
199
+ node.specifiers.forEach((specifier) => {
200
+ selectKitComponents.add(specifier.local.name);
201
+ });
202
+ }
203
+
204
+ const glimmerComponentName = getImportIdentifier(
205
+ node,
206
+ "@glimmer/component"
207
+ );
208
+ if (glimmerComponentName) {
209
+ componentNames.add(glimmerComponentName);
210
+ }
211
+
212
+ const emberComponentName = getImportIdentifier(
213
+ node,
214
+ "@ember/component"
215
+ );
216
+ if (emberComponentName) {
217
+ componentNames.add(emberComponentName);
218
+ }
219
+ },
220
+
221
+ ClassDeclaration: handleClass,
222
+ ClassExpression: handleClass,
223
+
224
+ "ClassDeclaration:exit": handleClassExit,
225
+ "ClassExpression:exit": handleClassExit,
226
+
227
+ ClassProperty: handleTrackedProperty,
228
+ PropertyDefinition: handleTrackedProperty,
229
+
230
+ AssignmentExpression(node) {
231
+ markAssigned(getAssignedPropertyName(node.left));
232
+ },
233
+
234
+ UpdateExpression(node) {
235
+ markAssigned(getAssignedPropertyName(node.argument));
236
+ },
237
+
238
+ GlimmerSubExpression: handleGlimmerSubExpression,
239
+ GlimmerElementNode: handleGlimmerElementNode,
240
+ };
241
+ },
242
+ };
package/eslint.mjs CHANGED
@@ -21,12 +21,14 @@ import keepArraySorted from "./eslint-rules/keep-array-sorted.mjs";
21
21
  import lineAfterImports from "./eslint-rules/line-after-imports.mjs";
22
22
  import lineBeforeDefaultExport from "./eslint-rules/line-before-default-export.mjs";
23
23
  import linesBetweenClassMembers from "./eslint-rules/lines-between-class-members.mjs";
24
+ import migrateTrackedBuiltInsToEmberCollections from "./eslint-rules/migrate-tracked-built-ins-to-ember-collections.mjs";
24
25
  import movedPackagesImportPaths from "./eslint-rules/moved-packages-import-paths.mjs";
25
26
  import noCurlyComponents from "./eslint-rules/no-curly-components.mjs";
26
27
  import noDiscourseComputed from "./eslint-rules/no-discourse-computed.mjs";
27
28
  import noOnclick from "./eslint-rules/no-onclick.mjs";
28
29
  import noRouteTemplate from "./eslint-rules/no-route-template.mjs";
29
30
  import noSimpleQuerySelector from "./eslint-rules/no-simple-query-selector.mjs";
31
+ import noUnnecessaryTracked from "./eslint-rules/no-unnecessary-tracked.mjs";
30
32
  import noUnusedServices from "./eslint-rules/no-unused-services.mjs";
31
33
  import pluginApiNoVersion from "./eslint-rules/plugin-api-no-version.mjs";
32
34
  import serviceInjectImport from "./eslint-rules/service-inject-import.mjs";
@@ -146,6 +148,9 @@ export default [
146
148
  "moved-packages-import-paths": movedPackagesImportPaths,
147
149
  "no-discourse-computed": noDiscourseComputed,
148
150
  "test-filename-suffix": testFilenameSuffix,
151
+ "no-unnecessary-tracked": noUnnecessaryTracked,
152
+ "migrate-tracked-built-ins-to-ember-collections":
153
+ migrateTrackedBuiltInsToEmberCollections,
149
154
  },
150
155
  },
151
156
  },
@@ -317,6 +322,8 @@ export default [
317
322
  "discourse/test-filename-suffix": ["error"],
318
323
  "discourse/keep-array-sorted": ["error"],
319
324
  "discourse/no-discourse-computed": ["error"],
325
+ "discourse/no-unnecessary-tracked": ["warn"],
326
+ "discourse/migrate-tracked-built-ins-to-ember-collections": ["error"],
320
327
  },
321
328
  },
322
329
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@discourse/lint-configs",
3
- "version": "2.42.0",
3
+ "version": "2.44.0",
4
4
  "description": "Shareable lint configs for Discourse core, plugins, and themes",
5
5
  "author": "Discourse",
6
6
  "license": "MIT",