@discourse/lint-configs 2.31.0 → 2.32.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.
@@ -35,6 +35,21 @@ export default {
35
35
  );
36
36
  },
37
37
  });
38
+ } else if (
39
+ node.source.value === "@ember/application" &&
40
+ node.specifiers[0]?.local.name === "getOwner"
41
+ ) {
42
+ context.report({
43
+ node,
44
+ message:
45
+ "Use '@ember/owner' instead of '@ember/application' to import 'getOwner'",
46
+ fix(fixer) {
47
+ return fixer.replaceText(
48
+ node,
49
+ `import { getOwner } from "@ember/owner";`
50
+ );
51
+ },
52
+ });
38
53
  }
39
54
  },
40
55
  };
@@ -0,0 +1,447 @@
1
+ // Based on https://github.com/ember-cli/eslint-plugin-ember/blob/8e4b717c1d7d2c0555f4de807709156c89f7aa7a/lib/rules/no-unused-services.js
2
+
3
+ const MACROS_TO_TRACKED_ARGUMENT_COUNT = {
4
+ alias: 1,
5
+ and: Number.MAX_VALUE,
6
+ bool: 1,
7
+ collect: Number.MAX_VALUE,
8
+ deprecatingAlias: 1,
9
+ empty: 1,
10
+ equal: 1,
11
+ filter: 1,
12
+ filterBy: 1,
13
+ gt: 1,
14
+ gte: 1,
15
+ intersect: Number.MAX_VALUE,
16
+ lt: 1,
17
+ lte: 1,
18
+ map: 1,
19
+ mapBy: 1,
20
+ match: 1,
21
+ max: 1,
22
+ min: 1,
23
+ none: 1,
24
+ not: 1,
25
+ notEmpty: 1,
26
+ oneWay: 1,
27
+ or: Number.MAX_VALUE,
28
+ readOnly: 1,
29
+ reads: 1,
30
+ setDiff: 2,
31
+ sort: 1,
32
+ sum: Number.MAX_VALUE,
33
+ union: Number.MAX_VALUE,
34
+ uniq: 1,
35
+ uniqBy: 1,
36
+ };
37
+
38
+ const EMBER_MACROS = Object.keys(MACROS_TO_TRACKED_ARGUMENT_COUNT);
39
+
40
+ function splitValue(value) {
41
+ return value ? value.split(".")[0] : undefined;
42
+ }
43
+
44
+ function getImportIdentifier(node, source, namedImportIdentifier = null) {
45
+ if (node.source.value !== source) {
46
+ return null;
47
+ }
48
+
49
+ return node.specifiers
50
+ .filter((specifier) => {
51
+ return (
52
+ (specifier.type === "ImportSpecifier" &&
53
+ specifier.imported.name === namedImportIdentifier) ||
54
+ (!namedImportIdentifier && specifier.type === "ImportDefaultSpecifier")
55
+ );
56
+ })
57
+ .map((specifier) => specifier.local.name)
58
+ .pop();
59
+ }
60
+
61
+ function isObserverDecorator(node, importedObservesName) {
62
+ return (
63
+ node?.type === "Decorator" &&
64
+ node.expression?.type === "CallExpression" &&
65
+ node.expression.callee?.type === "Identifier" &&
66
+ node.expression.callee.name === importedObservesName
67
+ );
68
+ }
69
+
70
+ function isPropOfType(node, importedServiceName, importedOptionalServiceName) {
71
+ if (
72
+ (node?.type === "ClassProperty" ||
73
+ node?.type === "PropertyDefinition" ||
74
+ node?.type === "MethodDefinition") &&
75
+ node.decorators
76
+ ) {
77
+ return node.decorators.some((decorator) => {
78
+ const expression = decorator.expression;
79
+ return (
80
+ (expression?.type === "Identifier" &&
81
+ (expression.name === importedServiceName ||
82
+ expression.name === importedOptionalServiceName)) ||
83
+ (expression?.type === "CallExpression" &&
84
+ (expression.callee.name === importedServiceName ||
85
+ expression.callee.name === importedOptionalServiceName))
86
+ );
87
+ });
88
+ }
89
+ return false;
90
+ }
91
+
92
+ function isThisGetCall(node) {
93
+ if (
94
+ node?.type === "CallExpression" &&
95
+ node.callee?.type === "MemberExpression" &&
96
+ node.callee.object?.type === "ThisExpression" &&
97
+ node.callee.property?.type === "Identifier" &&
98
+ node.callee.property.name === "get" &&
99
+ node.arguments.length === 1 &&
100
+ node.arguments[0]?.type === "Literal" &&
101
+ typeof node.arguments[0]?.value === "string"
102
+ ) {
103
+ // Looks like: this.get('property')
104
+ return true;
105
+ }
106
+
107
+ return false;
108
+ }
109
+
110
+ function isComputedProp(node, importedComputedName) {
111
+ return (
112
+ // computed
113
+ (node?.type === "Identifier" && node.name === importedComputedName) ||
114
+ // computed()
115
+ (node?.type === "CallExpression" &&
116
+ node.callee?.type === "Identifier" &&
117
+ node.callee.name === importedComputedName)
118
+ );
119
+ }
120
+
121
+ export default {
122
+ meta: {
123
+ type: "suggestion",
124
+ docs: {
125
+ description:
126
+ "disallow unused service injections (see rule doc for limitations)",
127
+ category: "Services",
128
+ recommended: false,
129
+ url: "https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/no-unused-services.md",
130
+ },
131
+ fixable: null,
132
+ hasSuggestions: true,
133
+ schema: [],
134
+ messages: {
135
+ main: "The service `{{name}}` is not referenced in this file and might be unused (note: it could still be used in a parent/child class).",
136
+ removeServiceInjection: "Remove the service injection.",
137
+ },
138
+ },
139
+
140
+ create(context) {
141
+ let currentClass;
142
+
143
+ let importedComputedName;
144
+ let importedDiscourseComputedName;
145
+ let importedGetName;
146
+ let importedGetPropertiesName;
147
+ let importedServiceName;
148
+ let importedOptionalServiceName;
149
+ let importedObserverName;
150
+ let importedObservesName;
151
+ const importedMacros = {};
152
+
153
+ /**
154
+ * Gets the trailing comma token of the given node.
155
+ * If the trailing comma does not exist, this returns undefined.
156
+ * @param {ASTNode} node The given node
157
+ * @returns {Token|undefined} The trailing comma token or undefined
158
+ */
159
+ function getTrailingToken(node) {
160
+ const nextToken = context.sourceCode.getTokenAfter(node);
161
+ return nextToken.type === "Punctuator" && nextToken.value === ","
162
+ ? nextToken
163
+ : undefined;
164
+ }
165
+
166
+ /**
167
+ * Go through the current class and report any unused services
168
+ * @returns {void}
169
+ */
170
+ function reportInstances() {
171
+ const { services, uses } = currentClass;
172
+ currentClass = null;
173
+
174
+ if (Object.keys(services).length === 0) {
175
+ return;
176
+ }
177
+
178
+ for (const name of Object.keys(services)) {
179
+ if (!uses.has(name)) {
180
+ const node = services[name];
181
+ context.report({
182
+ node,
183
+ data: { name },
184
+ messageId: "main",
185
+ suggest: [
186
+ {
187
+ messageId: "removeServiceInjection",
188
+ fix(fixer) {
189
+ const fixers = [fixer.remove(node)];
190
+ if (node?.type === "Property") {
191
+ const trailingTokenNode = getTrailingToken(node);
192
+ if (trailingTokenNode) {
193
+ fixers.push(fixer.remove(trailingTokenNode));
194
+ }
195
+ }
196
+ return fixers;
197
+ },
198
+ },
199
+ ],
200
+ });
201
+ }
202
+ }
203
+ }
204
+
205
+ return {
206
+ ImportDeclaration(node) {
207
+ if (node.source.value === "@ember/object") {
208
+ importedComputedName ||= getImportIdentifier(
209
+ node,
210
+ "@ember/object",
211
+ "computed"
212
+ );
213
+ importedGetName ||= getImportIdentifier(node, "@ember/object", "get");
214
+ importedGetPropertiesName ||= getImportIdentifier(
215
+ node,
216
+ "@ember/object",
217
+ "getProperties"
218
+ );
219
+ importedObserverName ||= getImportIdentifier(
220
+ node,
221
+ "@ember/object",
222
+ "observer"
223
+ );
224
+ } else if (node.source.value === "@ember/object/computed") {
225
+ for (const spec of node.specifiers) {
226
+ if (spec.type === "ImportDefaultSpecifier") {
227
+ continue;
228
+ }
229
+ const name = spec.imported.name;
230
+ if (EMBER_MACROS.includes(name)) {
231
+ const localName = spec.local.name;
232
+ importedMacros[localName] = name;
233
+ }
234
+ }
235
+ } else if (node.source.value === "discourse/lib/decorators") {
236
+ importedDiscourseComputedName ||= getImportIdentifier(
237
+ node,
238
+ "discourse/lib/decorators"
239
+ );
240
+ } else if (node.source.value === "@ember/service") {
241
+ importedServiceName ||= getImportIdentifier(
242
+ node,
243
+ "@ember/service",
244
+ "service"
245
+ );
246
+ } else if (node.source.value === "discourse/lib/optional-service") {
247
+ importedOptionalServiceName ||= getImportIdentifier(
248
+ node,
249
+ "discourse/lib/optional-service"
250
+ );
251
+ } else if (node.source.value === "@ember-decorators/object") {
252
+ importedObservesName ||= getImportIdentifier(
253
+ node,
254
+ "@ember-decorators/object",
255
+ "observes"
256
+ );
257
+ }
258
+ },
259
+
260
+ // Native JS class
261
+ ClassDeclaration(node) {
262
+ currentClass = { node, services: {}, uses: new Set() };
263
+ },
264
+
265
+ CallExpression(node) {
266
+ if (!currentClass) {
267
+ return;
268
+ }
269
+
270
+ if (isComputedProp(node, importedComputedName)) {
271
+ // computed()
272
+ for (const elem of node.arguments) {
273
+ if (elem?.type === "Literal" && typeof elem?.value === "string") {
274
+ const name = splitValue(elem.value);
275
+ currentClass.uses.add(name);
276
+ }
277
+ }
278
+ } else if (isComputedProp(node, importedDiscourseComputedName)) {
279
+ // discourseComputed()
280
+ for (const elem of node.arguments) {
281
+ if (elem?.type === "Literal" && typeof elem?.value === "string") {
282
+ const name = splitValue(elem.value);
283
+ currentClass.uses.add(name);
284
+ }
285
+ }
286
+ } else if (isThisGetCall(node)) {
287
+ // this.get('foo...');
288
+ const name = splitValue(node.arguments[0].value);
289
+ currentClass.uses.add(name);
290
+ } else if (
291
+ node.callee.object?.type === "ThisExpression" &&
292
+ node.callee.property.name === "getProperties"
293
+ ) {
294
+ // this.getProperties([..., 'foo..', ...]); or this.getProperties(..., 'foo..', ...);
295
+ const argArray =
296
+ node.arguments[0]?.type === "ArrayExpression"
297
+ ? node.arguments[0].elements
298
+ : node.arguments;
299
+ for (const elem of argArray) {
300
+ const name = splitValue(elem.value);
301
+ currentClass.uses.add(name);
302
+ }
303
+ } else if (node.callee?.type === "Identifier") {
304
+ const calleeName = node.callee.name;
305
+ if (node.arguments[0]?.type === "ThisExpression") {
306
+ // If `get` and `getProperties` weren't imported, skip out early
307
+ if (!importedGetName && !importedGetPropertiesName) {
308
+ return;
309
+ }
310
+
311
+ if (calleeName === importedGetName) {
312
+ // get(this, 'foo...');
313
+ const name = splitValue(node.arguments[1].value);
314
+ currentClass.uses.add(name);
315
+ } else if (calleeName === importedGetPropertiesName) {
316
+ // getProperties(this, [..., 'foo..', ...]); or getProperties(this, ..., 'foo..', ...);
317
+ const argArray =
318
+ node.arguments[1]?.type === "ArrayExpression"
319
+ ? node.arguments[1].elements
320
+ : node.arguments.slice(1);
321
+ for (const elem of argArray) {
322
+ const name = splitValue(elem.value);
323
+ currentClass.uses.add(name);
324
+ }
325
+ }
326
+ } else if (importedMacros[calleeName]) {
327
+ // Computed macros like @alias(), @or()
328
+ const macroName = importedMacros[calleeName];
329
+ for (
330
+ let idx = 0;
331
+ idx < MACROS_TO_TRACKED_ARGUMENT_COUNT[macroName] &&
332
+ idx < node.arguments.length;
333
+ idx++
334
+ ) {
335
+ const elem = node.arguments[idx];
336
+ if (elem?.type === "Literal" && typeof elem?.value === "string") {
337
+ const name = splitValue(elem.value);
338
+ currentClass.uses.add(name);
339
+ }
340
+ }
341
+ } else if (calleeName === importedObserverName) {
342
+ // observer('foo', ...)
343
+ for (const elem of node.arguments) {
344
+ if (elem?.type === "Literal" && typeof elem?.value === "string") {
345
+ const name = splitValue(elem.value);
346
+ currentClass.uses.add(name);
347
+ }
348
+ }
349
+ }
350
+ }
351
+ },
352
+
353
+ "ClassDeclaration:exit"(node) {
354
+ if (currentClass && currentClass.node === node) {
355
+ // Leaving current class
356
+ reportInstances();
357
+ }
358
+ },
359
+
360
+ // @observes('foo', ...)
361
+ Decorator(node) {
362
+ // If `service` and `optionalService` weren't imported OR observes wasn't imported, skip out early
363
+ if (
364
+ (!importedServiceName && !importedOptionalServiceName) ||
365
+ !importedObservesName
366
+ ) {
367
+ return;
368
+ }
369
+
370
+ if (
371
+ currentClass &&
372
+ isObserverDecorator(node, importedObservesName) &&
373
+ node.expression?.type === "CallExpression"
374
+ ) {
375
+ for (const elem of node.expression.arguments) {
376
+ if (elem.type === "Literal" && typeof elem.value === "string") {
377
+ const name = splitValue(elem.value);
378
+ currentClass.uses.add(name);
379
+ }
380
+ }
381
+ }
382
+ },
383
+
384
+ PropertyDefinition(node) {
385
+ // Handles:
386
+ // @service(...) foo;
387
+ // @optionalService(...) foo;
388
+
389
+ // If `service` and `optionalService` weren't imported, skip out early
390
+ if (!importedServiceName && !importedOptionalServiceName) {
391
+ return;
392
+ }
393
+
394
+ if (
395
+ currentClass &&
396
+ isPropOfType(node, importedServiceName, importedOptionalServiceName)
397
+ ) {
398
+ if (node.key.type === "Identifier") {
399
+ const name = node.key.name;
400
+ currentClass.services[name] = node;
401
+ } else if (
402
+ node.key?.type === "Literal" &&
403
+ typeof node.key?.value === "string"
404
+ ) {
405
+ const name = node.key.value;
406
+ currentClass.services[name] = node;
407
+ }
408
+ }
409
+ },
410
+
411
+ // this.foo...
412
+ MemberExpression(node) {
413
+ if (
414
+ currentClass &&
415
+ node.object?.type === "ThisExpression" &&
416
+ node.property?.type === "Identifier"
417
+ ) {
418
+ const name = node.property.name;
419
+ currentClass.uses.add(name);
420
+ }
421
+ },
422
+
423
+ GlimmerPathExpression(node) {
424
+ if (
425
+ currentClass &&
426
+ node.head.type === "ThisHead" &&
427
+ node.tail.length > 0
428
+ ) {
429
+ const name = node.tail[0];
430
+ currentClass.uses.add(name);
431
+ }
432
+ },
433
+
434
+ VariableDeclarator(node) {
435
+ if (
436
+ currentClass &&
437
+ node.init?.type === "ThisExpression" &&
438
+ node.id?.type === "ObjectPattern"
439
+ ) {
440
+ for (const property of node.id.properties) {
441
+ currentClass.uses.add(property.key.name);
442
+ }
443
+ }
444
+ },
445
+ };
446
+ },
447
+ };
package/eslint.mjs CHANGED
@@ -25,6 +25,7 @@ import linesBetweenClassMembers from "./eslint-rules/lines-between-class-members
25
25
  import noCurlyComponents from "./eslint-rules/no-curly-components.mjs";
26
26
  import noOnclick from "./eslint-rules/no-onclick.mjs";
27
27
  import noSimpleQuerySelector from "./eslint-rules/no-simple-query-selector.mjs";
28
+ import noUnusedServices from "./eslint-rules/no-unused-services.mjs";
28
29
  import pluginApiNoVersion from "./eslint-rules/plugin-api-no-version.mjs";
29
30
  import serviceInjectImport from "./eslint-rules/service-inject-import.mjs";
30
31
  import themeImports from "./eslint-rules/theme-imports.mjs";
@@ -121,6 +122,7 @@ export default [
121
122
  "i18n-t": i18nT,
122
123
  "service-inject-import": serviceInjectImport,
123
124
  "truth-helpers-imports": truthHelpersImports,
125
+ "no-unused-services": noUnusedServices,
124
126
  "plugin-api-no-version": pluginApiNoVersion,
125
127
  "theme-imports": themeImports,
126
128
  "no-simple-query-selector": noSimpleQuerySelector,
@@ -296,6 +298,7 @@ export default [
296
298
  "discourse/i18n-t": ["error"],
297
299
  "discourse/service-inject-import": ["error"],
298
300
  "discourse/truth-helpers-imports": ["error"],
301
+ "discourse/no-unused-services": ["error"],
299
302
  "discourse/plugin-api-no-version": ["error"],
300
303
  "discourse/theme-imports": ["error"],
301
304
  "discourse/no-simple-query-selector": ["error"],
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@discourse/lint-configs",
3
- "version": "2.31.0",
3
+ "version": "2.32.0",
4
4
  "description": "Shareable lint configs for Discourse core, plugins, and themes",
5
5
  "author": "Discourse",
6
6
  "license": "MIT",
@@ -30,13 +30,13 @@
30
30
  "test": "cd ../test && node test.js"
31
31
  },
32
32
  "dependencies": {
33
- "@babel/core": "^7.28.0",
33
+ "@babel/core": "^7.28.4",
34
34
  "@babel/eslint-parser": "^7.28.0",
35
35
  "@babel/plugin-proposal-decorators": "^7.28.0",
36
- "ember-template-lint": "^7.9.1",
37
- "eslint": "^9.32.0",
36
+ "ember-template-lint": "^7.9.3",
37
+ "eslint": "^9.36.0",
38
38
  "eslint-plugin-decorator-position": "^6.0.0",
39
- "eslint-plugin-ember": "^12.7.0",
39
+ "eslint-plugin-ember": "^12.7.4",
40
40
  "eslint-plugin-import": "^2.32.0",
41
41
  "eslint-plugin-qunit": "^8.2.5",
42
42
  "eslint-plugin-simple-import-sort": "^12.1.1",
@@ -44,16 +44,16 @@
44
44
  "globals": "^16.3.0",
45
45
  "prettier": "^3.6.2",
46
46
  "prettier-plugin-ember-template-tag": "^2.1.0",
47
- "stylelint": "^16.22.0",
48
- "stylelint-config-standard": "^38.0.0",
47
+ "stylelint": "^16.24.0",
48
+ "stylelint-config-standard": "^39.0.0",
49
49
  "stylelint-config-standard-scss": "^15.0.1",
50
50
  "stylelint-scss": "^6.12.1",
51
- "typescript": "^5.8.3"
51
+ "typescript": "^5.9.2"
52
52
  },
53
53
  "peerDependencies": {
54
- "ember-template-lint": "7.9.1",
55
- "eslint": "9.32.0",
54
+ "ember-template-lint": "7.9.3",
55
+ "eslint": "9.36.0",
56
56
  "prettier": "3.6.2",
57
- "stylelint": "16.22.0"
57
+ "stylelint": "16.24.0"
58
58
  }
59
59
  }
@@ -11,7 +11,6 @@ module.exports = {
11
11
 
12
12
  // Pending default rules
13
13
  "link-href-attributes": false,
14
- "no-action": false,
15
14
  "no-at-ember-render-modifiers": false,
16
15
  "no-curly-component-invocation": false,
17
16
  "no-duplicate-landmark-elements": false,
@@ -34,7 +33,6 @@ module.exports = {
34
33
  // Pending non-default rules
35
34
  "attribute-order": false,
36
35
  "inline-link-to": false,
37
- "no-action-modifiers": false,
38
36
  "no-builtin-form-components": false,
39
37
  "no-this-in-template-only-components": false, // emits false-positives in gjs
40
38
 
@@ -61,13 +59,6 @@ module.exports = {
61
59
  files: ["**/*.gjs", "**/*.gts"],
62
60
  rules: {
63
61
  "discourse/no-implicit-this": false,
64
- "no-action-modifiers": true,
65
- },
66
- },
67
- {
68
- files: ["**/templates/**/*.gjs"],
69
- rules: {
70
- "no-action": true,
71
62
  },
72
63
  },
73
64
  ],