@discourse/lint-configs 2.31.0 → 2.33.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/.prettierrc.cjs CHANGED
@@ -6,12 +6,14 @@ module.exports = {
6
6
  files: "*.gjs",
7
7
  options: {
8
8
  parser: "ember-template-tag",
9
+ templateExportDefault: true,
9
10
  },
10
11
  },
11
12
  {
12
13
  files: "*.gts",
13
14
  options: {
14
15
  parser: "ember-template-tag",
16
+ templateExportDefault: true,
15
17
  },
16
18
  },
17
19
  ],
@@ -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,55 @@
1
+ export default {
2
+ meta: {
3
+ type: "suggestion",
4
+ docs: {
5
+ description: "Disallow RouteTemplate wrapper for route templates.",
6
+ },
7
+ fixable: "code",
8
+ schema: [],
9
+ messages: {
10
+ removeRouteTemplate: "Remove RouteTemplate wrapper for route templates.",
11
+ },
12
+ },
13
+
14
+ create(context) {
15
+ return {
16
+ ExportDefaultDeclaration(node) {
17
+ if (
18
+ node.declaration &&
19
+ node.declaration.type === "CallExpression" &&
20
+ node.declaration.callee.name === "RouteTemplate" &&
21
+ node.declaration.arguments.length === 1
22
+ ) {
23
+ context.report({
24
+ node,
25
+ messageId: "removeRouteTemplate",
26
+ fix(fixer) {
27
+ const sourceCode = context.getSourceCode();
28
+ const arg = node.declaration.arguments[0];
29
+ // Find import of RouteTemplate
30
+ const importDecl = sourceCode.ast.body.find(
31
+ (n) =>
32
+ n.type === "ImportDeclaration" &&
33
+ n.specifiers.some(
34
+ (s) =>
35
+ s.type === "ImportDefaultSpecifier" &&
36
+ s.local.name === "RouteTemplate"
37
+ )
38
+ );
39
+
40
+ // Only remove the import and replace the export, do not touch whitespace/indentation
41
+ const fixes = [];
42
+ if (importDecl) {
43
+ fixes.push(fixer.remove(importDecl));
44
+ }
45
+ fixes.push(
46
+ fixer.replaceText(node.declaration, sourceCode.getText(arg))
47
+ );
48
+ return fixes;
49
+ },
50
+ });
51
+ }
52
+ },
53
+ };
54
+ },
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
@@ -24,7 +24,9 @@ import lineBeforeDefaultExport from "./eslint-rules/line-before-default-export.m
24
24
  import linesBetweenClassMembers from "./eslint-rules/lines-between-class-members.mjs";
25
25
  import noCurlyComponents from "./eslint-rules/no-curly-components.mjs";
26
26
  import noOnclick from "./eslint-rules/no-onclick.mjs";
27
+ import noRouteTemplate from "./eslint-rules/no-route-template.mjs";
27
28
  import noSimpleQuerySelector from "./eslint-rules/no-simple-query-selector.mjs";
29
+ import noUnusedServices from "./eslint-rules/no-unused-services.mjs";
28
30
  import pluginApiNoVersion from "./eslint-rules/plugin-api-no-version.mjs";
29
31
  import serviceInjectImport from "./eslint-rules/service-inject-import.mjs";
30
32
  import themeImports from "./eslint-rules/theme-imports.mjs";
@@ -121,6 +123,7 @@ export default [
121
123
  "i18n-t": i18nT,
122
124
  "service-inject-import": serviceInjectImport,
123
125
  "truth-helpers-imports": truthHelpersImports,
126
+ "no-unused-services": noUnusedServices,
124
127
  "plugin-api-no-version": pluginApiNoVersion,
125
128
  "theme-imports": themeImports,
126
129
  "no-simple-query-selector": noSimpleQuerySelector,
@@ -134,6 +137,7 @@ export default [
134
137
  "no-curly-components": noCurlyComponents,
135
138
  "capital-components": capitalComponents,
136
139
  "no-onclick": noOnclick,
140
+ "no-route-template": noRouteTemplate,
137
141
  },
138
142
  },
139
143
  },
@@ -296,6 +300,7 @@ export default [
296
300
  "discourse/i18n-t": ["error"],
297
301
  "discourse/service-inject-import": ["error"],
298
302
  "discourse/truth-helpers-imports": ["error"],
303
+ "discourse/no-unused-services": ["error"],
299
304
  "discourse/plugin-api-no-version": ["error"],
300
305
  "discourse/theme-imports": ["error"],
301
306
  "discourse/no-simple-query-selector": ["error"],
@@ -309,6 +314,7 @@ export default [
309
314
  "discourse/no-curly-components": ["error"],
310
315
  "discourse/capital-components": ["error"],
311
316
  "discourse/no-onclick": ["error"],
317
+ // "discourse/no-route-template": ["error"], // Enable by default once Ember 6.6 is on stable
312
318
  },
313
319
  },
314
320
  {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@discourse/lint-configs",
3
- "version": "2.31.0",
3
+ "version": "2.33.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
  ],