@discourse/lint-configs 2.43.0 → 2.44.1

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
+ };
package/eslint.mjs CHANGED
@@ -21,6 +21,7 @@ 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";
@@ -148,6 +149,8 @@ export default [
148
149
  "no-discourse-computed": noDiscourseComputed,
149
150
  "test-filename-suffix": testFilenameSuffix,
150
151
  "no-unnecessary-tracked": noUnnecessaryTracked,
152
+ "migrate-tracked-built-ins-to-ember-collections":
153
+ migrateTrackedBuiltInsToEmberCollections,
151
154
  },
152
155
  },
153
156
  },
@@ -278,14 +281,7 @@ export default [
278
281
  "",
279
282
  // Internal
280
283
  "^discourse/",
281
- "^discourse-common/",
282
284
  "^discourse-.+",
283
- "^admin/",
284
- "^wizard/",
285
- "^I18n$",
286
- "^select-kit/",
287
- "^float-kit/",
288
- "^truth-helpers/",
289
285
  // Plugins
290
286
  "^discourse/plugins/",
291
287
  // Relative
@@ -320,6 +316,7 @@ export default [
320
316
  "discourse/keep-array-sorted": ["error"],
321
317
  "discourse/no-discourse-computed": ["error"],
322
318
  "discourse/no-unnecessary-tracked": ["warn"],
319
+ "discourse/migrate-tracked-built-ins-to-ember-collections": ["error"],
323
320
  },
324
321
  },
325
322
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@discourse/lint-configs",
3
- "version": "2.43.0",
3
+ "version": "2.44.1",
4
4
  "description": "Shareable lint configs for Discourse core, plugins, and themes",
5
5
  "author": "Discourse",
6
6
  "license": "MIT",
@@ -45,7 +45,7 @@
45
45
  "globals": "^17.4.0",
46
46
  "prettier": "^3.8.1",
47
47
  "prettier-plugin-ember-template-tag": "^2.1.3",
48
- "stylelint": "^17.4.0",
48
+ "stylelint": "^17.5.0",
49
49
  "stylelint-config-standard": "^40.0.0",
50
50
  "stylelint-config-standard-scss": "^17.0.0",
51
51
  "stylelint-scss": "^7.0.0",
@@ -55,7 +55,7 @@
55
55
  "ember-template-lint": "7.9.3",
56
56
  "eslint": "9.39.2",
57
57
  "prettier": "3.8.1",
58
- "stylelint": "17.4.0"
58
+ "stylelint": "17.5.0"
59
59
  },
60
60
  "scripts": {
61
61
  "lint": "eslint --no-error-on-unmatched-pattern \"**/*.{cjs,mjs,js}\" && pnpm prettier --check \"**/*.{cjs,mjs,js}\"",
package/stylelint.mjs CHANGED
@@ -33,6 +33,12 @@ export default {
33
33
  true,
34
34
  { ignoreKeywords: ["break-word"] },
35
35
  ],
36
+ "value-keyword-case": [
37
+ "lower",
38
+ {
39
+ ignoreProperties: ["/^\\$/", "font", "font-family"],
40
+ },
41
+ ],
36
42
 
37
43
  "discourse/no-breakpoint-mixin": true,
38
44
  },