@discourse/lint-configs 2.30.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,24 @@
1
+ export default {
2
+ meta: {
3
+ type: "suggestion",
4
+ docs: {
5
+ description: "Avoid the `onclick` attribute",
6
+ },
7
+ fixable: "code",
8
+ schema: [], // no options
9
+ },
10
+
11
+ create(context) {
12
+ return {
13
+ GlimmerAttrNode(node) {
14
+ if (node.name === "onclick") {
15
+ context.report({
16
+ node,
17
+ message:
18
+ 'Do not use `onclick` attribute. Use `{{on "click" ...}}` modifier instead.',
19
+ });
20
+ }
21
+ },
22
+ };
23
+ },
24
+ };
@@ -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
@@ -23,7 +23,9 @@ import lineAfterImports from "./eslint-rules/line-after-imports.mjs";
23
23
  import lineBeforeDefaultExport from "./eslint-rules/line-before-default-export.mjs";
24
24
  import linesBetweenClassMembers from "./eslint-rules/lines-between-class-members.mjs";
25
25
  import noCurlyComponents from "./eslint-rules/no-curly-components.mjs";
26
+ import noOnclick from "./eslint-rules/no-onclick.mjs";
26
27
  import noSimpleQuerySelector from "./eslint-rules/no-simple-query-selector.mjs";
28
+ import noUnusedServices from "./eslint-rules/no-unused-services.mjs";
27
29
  import pluginApiNoVersion from "./eslint-rules/plugin-api-no-version.mjs";
28
30
  import serviceInjectImport from "./eslint-rules/service-inject-import.mjs";
29
31
  import themeImports from "./eslint-rules/theme-imports.mjs";
@@ -120,6 +122,7 @@ export default [
120
122
  "i18n-t": i18nT,
121
123
  "service-inject-import": serviceInjectImport,
122
124
  "truth-helpers-imports": truthHelpersImports,
125
+ "no-unused-services": noUnusedServices,
123
126
  "plugin-api-no-version": pluginApiNoVersion,
124
127
  "theme-imports": themeImports,
125
128
  "no-simple-query-selector": noSimpleQuerySelector,
@@ -132,6 +135,7 @@ export default [
132
135
  "line-before-default-export": lineBeforeDefaultExport,
133
136
  "no-curly-components": noCurlyComponents,
134
137
  "capital-components": capitalComponents,
138
+ "no-onclick": noOnclick,
135
139
  },
136
140
  },
137
141
  },
@@ -294,6 +298,7 @@ export default [
294
298
  "discourse/i18n-t": ["error"],
295
299
  "discourse/service-inject-import": ["error"],
296
300
  "discourse/truth-helpers-imports": ["error"],
301
+ "discourse/no-unused-services": ["error"],
297
302
  "discourse/plugin-api-no-version": ["error"],
298
303
  "discourse/theme-imports": ["error"],
299
304
  "discourse/no-simple-query-selector": ["error"],
@@ -306,6 +311,7 @@ export default [
306
311
  "discourse/line-before-default-export": ["error"],
307
312
  "discourse/no-curly-components": ["error"],
308
313
  "discourse/capital-components": ["error"],
314
+ "discourse/no-onclick": ["error"],
309
315
  },
310
316
  },
311
317
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@discourse/lint-configs",
3
- "version": "2.30.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
  ],