@discourse/lint-configs 2.40.0 → 2.42.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.
@@ -0,0 +1,614 @@
1
+ /**
2
+ * @fileoverview Analysis helpers for the `no-discourse-computed` ESLint rule.
3
+ *
4
+ * These helpers are intentionally isolated so they can be reused by other
5
+ * rules or tests. They perform read-only AST traversal and return detailed
6
+ * information about @discourseComputed usages to determine auto-fixability.
7
+ */
8
+
9
+ /**
10
+ * @typedef {Object} UsageInfo
11
+ * @property {string} [messageId] - The suggested messageId if not fixable
12
+ * @property {Object} [reportData] - Data for the report message
13
+ * @property {boolean} canAutoFix - Whether this specific usage is auto-fixable
14
+ * @property {boolean} isClassic - Whether this is a classic Ember class usage
15
+ * @property {Array} [simpleReassignments] - List of simple reassignments for the fixer
16
+ */
17
+
18
+ /**
19
+ * @typedef {Object} DiscourseComputedInfo
20
+ * @property {boolean} hasFixableDecorators
21
+ * @property {boolean} hasClassicClassDecorators
22
+ * @property {boolean} hasParameterReassignments
23
+ * @property {boolean} hasParametersInSpread
24
+ * @property {boolean} hasUnsafeOptionalChaining
25
+ * @property {boolean} hasParameterInNestedFunction
26
+ * @property {Map<import('estree').Node, UsageInfo>} usageMap - Map of nodes to their detailed usage info
27
+ */
28
+
29
+ /**
30
+ * Analyze the source AST to detect various usages of `@discourseComputed` that
31
+ * determine whether decorators are safe to auto-fix.
32
+ *
33
+ * @param {import('eslint').SourceCode} sourceCode - ESLint SourceCode instance
34
+ * @param {string|null} discourseComputedLocalName - local identifier name used for the discourseComputed import
35
+ * @returns {DiscourseComputedInfo}
36
+ */
37
+ export function analyzeDiscourseComputedUsage(
38
+ sourceCode,
39
+ discourseComputedLocalName
40
+ ) {
41
+ const info = {
42
+ hasFixableDecorators: false,
43
+ hasClassicClassDecorators: false,
44
+ hasParameterReassignments: false,
45
+ hasParametersInSpread: false,
46
+ hasUnsafeOptionalChaining: false,
47
+ hasParameterInNestedFunction: false,
48
+ usageMap: new Map(),
49
+ };
50
+
51
+ if (!discourseComputedLocalName) {
52
+ return info;
53
+ }
54
+
55
+ // Helper to traverse any node recursively
56
+ const traverseNode = (node) => {
57
+ if (!node || typeof node !== "object") {
58
+ return;
59
+ }
60
+
61
+ if (node.type === "ClassDeclaration" || node.type === "ClassExpression") {
62
+ analyzeClassBody(node.body, info);
63
+ }
64
+
65
+ if (
66
+ node.type === "CallExpression" &&
67
+ node.callee &&
68
+ node.callee.type === "MemberExpression" &&
69
+ node.callee.property &&
70
+ node.callee.property.name === "extend" &&
71
+ node.callee.object &&
72
+ node.callee.object.type === "Identifier" &&
73
+ /^(Component|Controller|Route|EmberObject|Service|Object)$/.test(
74
+ node.callee.object.name
75
+ )
76
+ ) {
77
+ node.arguments.forEach((arg) => {
78
+ if (arg.type === "ObjectExpression") {
79
+ analyzeObjectExpression(arg, info);
80
+ }
81
+ });
82
+ }
83
+
84
+ // Handle direct CallExpression of discourseComputed (classic classes)
85
+ if (
86
+ node.type === "CallExpression" &&
87
+ node.callee &&
88
+ node.callee.name === discourseComputedLocalName
89
+ ) {
90
+ // Check if this CallExpression is part of a decorator
91
+ const isDecorator = node.parent && node.parent.type === "Decorator";
92
+ if (!isDecorator) {
93
+ // Check if we're inside a .extend() call
94
+ let parent = node.parent;
95
+ let isClassicClass = false;
96
+ while (parent) {
97
+ if (
98
+ parent.type === "CallExpression" &&
99
+ parent.callee &&
100
+ parent.callee.type === "MemberExpression" &&
101
+ parent.callee.property &&
102
+ parent.callee.property.name === "extend" &&
103
+ parent.callee.object &&
104
+ parent.callee.object.type === "Identifier" &&
105
+ /^(Component|Controller|Route|EmberObject|Service|Object)$/.test(
106
+ parent.callee.object.name
107
+ )
108
+ ) {
109
+ isClassicClass = true;
110
+ break;
111
+ }
112
+ parent = parent.parent;
113
+ }
114
+
115
+ if (isClassicClass) {
116
+ info.hasClassicClassDecorators = true;
117
+ info.usageMap.set(node, {
118
+ canAutoFix: false,
119
+ isClassic: true,
120
+ messageId: "cannotAutoFixClassic",
121
+ reportData: { name: discourseComputedLocalName },
122
+ });
123
+ }
124
+ }
125
+ }
126
+
127
+ for (const key in node) {
128
+ if (key === "parent" || key === "range" || key === "loc") {
129
+ continue;
130
+ }
131
+ const child = node[key];
132
+ if (Array.isArray(child)) {
133
+ child.forEach((item) => traverseNode(item));
134
+ } else {
135
+ traverseNode(child);
136
+ }
137
+ }
138
+ };
139
+
140
+ // Analyze AST body
141
+ sourceCode.ast.body.forEach((statement) => traverseNode(statement));
142
+
143
+ return info;
144
+
145
+ // ---- local helpers ----
146
+
147
+ function analyzeClassBody(classBody, infoObj) {
148
+ if (!classBody || !classBody.body) {
149
+ return;
150
+ }
151
+
152
+ classBody.body.forEach((member) => {
153
+ if (member.type !== "MethodDefinition" || !member.decorators) {
154
+ return;
155
+ }
156
+
157
+ const discourseDecorator = member.decorators.find((decorator) => {
158
+ const expr = decorator.expression;
159
+ if (expr.type === "CallExpression") {
160
+ return expr.callee.name === discourseComputedLocalName;
161
+ }
162
+ return expr.name === discourseComputedLocalName;
163
+ });
164
+
165
+ if (!discourseDecorator) {
166
+ return;
167
+ }
168
+
169
+ const usageInfo = analyzeMethodUsage(member, discourseDecorator);
170
+ infoObj.usageMap.set(discourseDecorator, usageInfo);
171
+
172
+ // Update global summary flags
173
+ if (usageInfo.canAutoFix) {
174
+ infoObj.hasFixableDecorators = true;
175
+ } else if (usageInfo.isClassic) {
176
+ infoObj.hasClassicClassDecorators = true;
177
+ } else {
178
+ const mid = usageInfo.messageId;
179
+ if (mid === "cannotAutoFixNestedFunction") {
180
+ infoObj.hasParameterInNestedFunction = true;
181
+ } else if (mid === "cannotAutoFixUnsafeOptionalChaining") {
182
+ infoObj.hasUnsafeOptionalChaining = true;
183
+ } else if (mid === "cannotAutoFixSpread") {
184
+ infoObj.hasParametersInSpread = true;
185
+ } else {
186
+ infoObj.hasParameterReassignments = true;
187
+ }
188
+ }
189
+ });
190
+ }
191
+
192
+ /**
193
+ * Returns true if funcNode's body references `arguments` at the outer function
194
+ * scope — i.e., not inside a nested non-arrow function (which has its own
195
+ * `arguments` object unrelated to the method's parameters).
196
+ *
197
+ * @param {import('estree').Function} funcNode
198
+ * @returns {boolean}
199
+ */
200
+ function containsArgumentsReference(funcNode) {
201
+ let found = false;
202
+ function visit(node, insideNestedNonArrow) {
203
+ if (found || !node || typeof node !== "object") {
204
+ return;
205
+ }
206
+ if (
207
+ node.type === "Identifier" &&
208
+ node.name === "arguments" &&
209
+ !insideNestedNonArrow
210
+ ) {
211
+ found = true;
212
+ return;
213
+ }
214
+ const childFlag =
215
+ insideNestedNonArrow ||
216
+ node.type === "FunctionDeclaration" ||
217
+ node.type === "FunctionExpression";
218
+ for (const key of Object.keys(node)) {
219
+ if (key === "parent" || key === "range" || key === "loc") {
220
+ continue;
221
+ }
222
+ const child = node[key];
223
+ if (Array.isArray(child)) {
224
+ child.forEach((item) => visit(item, childFlag));
225
+ } else {
226
+ visit(child, childFlag);
227
+ }
228
+ }
229
+ }
230
+ visit(funcNode.body, false);
231
+ return found;
232
+ }
233
+
234
+ function analyzeMethodUsage(methodNode, decoratorNode) {
235
+ const decoratorExpression = decoratorNode.expression;
236
+ let decoratorArgs = [];
237
+ if (decoratorExpression.type === "CallExpression") {
238
+ decoratorArgs = decoratorExpression.arguments
239
+ .map((arg) => (arg.type === "Literal" ? arg.value : null))
240
+ .filter(Boolean);
241
+ }
242
+
243
+ const functionNode = methodNode.value;
244
+ const paramNames = functionNode.params.map((p) => p.name);
245
+
246
+ if (containsArgumentsReference(functionNode)) {
247
+ return {
248
+ canAutoFix: false,
249
+ messageId: "cannotAutoFixArguments",
250
+ reportData: { name: discourseComputedLocalName },
251
+ };
252
+ }
253
+
254
+ if (paramNames.length === 0) {
255
+ return {
256
+ canAutoFix: true,
257
+ reportData: { name: discourseComputedLocalName },
258
+ };
259
+ }
260
+
261
+ // Use ESLint scope analysis to find all references to parameters
262
+ const scope = sourceCode.getScope(functionNode);
263
+ const parameterReassignmentInfo = {};
264
+ let hasParameterInSpread = false;
265
+ let spreadParam = null;
266
+ let hasUnsafeOptionalChaining = false;
267
+ let unsafeOptionalChainingParam = null;
268
+ let hasParameterInNestedFunction = false;
269
+ let nestedFunctionParam = null;
270
+
271
+ for (const variable of scope.variables) {
272
+ if (!paramNames.includes(variable.name) || variable.scope !== scope) {
273
+ continue;
274
+ }
275
+
276
+ const paramIndex = paramNames.indexOf(variable.name);
277
+ const propertyPath = decoratorArgs[paramIndex] || variable.name;
278
+
279
+ for (const reference of variable.references) {
280
+ const refNode = reference.identifier;
281
+ const parent = refNode.parent;
282
+
283
+ // 1. Check for nested function usage (non-arrow)
284
+ // In ESLint scope, reference.from gives the scope where the reference occurs
285
+ let currentScope = reference.from;
286
+ while (currentScope && currentScope !== scope) {
287
+ if (
288
+ currentScope.type === "function" &&
289
+ currentScope.block.type !== "ArrowFunctionExpression"
290
+ ) {
291
+ hasParameterInNestedFunction = true;
292
+ nestedFunctionParam = variable.name;
293
+ break;
294
+ }
295
+ currentScope = currentScope.upper;
296
+ }
297
+
298
+ // 2. Check for reassignment
299
+ if (reference.isWrite()) {
300
+ if (!parameterReassignmentInfo[variable.name]) {
301
+ parameterReassignmentInfo[variable.name] = {
302
+ assignments: [],
303
+ hasUpdateExpression: false,
304
+ };
305
+ }
306
+
307
+ if (parent.type === "UpdateExpression") {
308
+ parameterReassignmentInfo[variable.name].hasUpdateExpression = true;
309
+ } else if (parent.type === "AssignmentExpression") {
310
+ // Determine nesting depth for reassignment
311
+ let depth = 0;
312
+ let ancestor = parent.parent;
313
+ while (ancestor && ancestor !== functionNode.body) {
314
+ if (
315
+ /^(If|For|While|DoWhile|Switch|Try)Statement$/.test(
316
+ ancestor.type
317
+ )
318
+ ) {
319
+ depth++;
320
+ }
321
+ ancestor = ancestor.parent;
322
+ }
323
+
324
+ parameterReassignmentInfo[variable.name].assignments.push({
325
+ node: parent,
326
+ depth,
327
+ });
328
+ }
329
+ }
330
+
331
+ // 3. Check for spread usage
332
+ let spreadCheck = parent;
333
+ while (spreadCheck && spreadCheck !== functionNode.body) {
334
+ if (spreadCheck.type === "SpreadElement") {
335
+ // Check if it's a "safe" spread pattern: ...(param || [])
336
+ const arg = spreadCheck.argument;
337
+ const isSafe =
338
+ arg.type === "LogicalExpression" &&
339
+ (arg.operator === "||" || arg.operator === "??") &&
340
+ arg.right.type === "ArrayExpression";
341
+
342
+ if (!isSafe) {
343
+ hasParameterInSpread = true;
344
+ spreadParam = variable.name;
345
+ }
346
+ break;
347
+ }
348
+ spreadCheck = spreadCheck.parent;
349
+ }
350
+
351
+ // 4. Check for unsafe optional chaining
352
+ const isNestedProperty =
353
+ propertyPath.includes(".") ||
354
+ propertyPath.includes("{") ||
355
+ propertyPath.includes("@") ||
356
+ propertyPath.includes("[");
357
+
358
+ if (isNestedProperty) {
359
+ let current = refNode;
360
+ let inUnsafeLogical = false;
361
+
362
+ while (
363
+ current.parent &&
364
+ (current.parent.type === "LogicalExpression" ||
365
+ current.parent.type === "ConditionalExpression" ||
366
+ current.parent.type === "MemberExpression")
367
+ ) {
368
+ const p = current.parent;
369
+
370
+ if (p.type === "LogicalExpression") {
371
+ const isSafeFallback =
372
+ (p.operator === "||" || p.operator === "??") &&
373
+ p.right.type === "Literal";
374
+ if (!isSafeFallback) {
375
+ inUnsafeLogical = true;
376
+ }
377
+ } else if (p.type === "ConditionalExpression") {
378
+ inUnsafeLogical = true;
379
+ } else if (p.type === "MemberExpression") {
380
+ if (inUnsafeLogical && p.object === current) {
381
+ hasUnsafeOptionalChaining = true;
382
+ unsafeOptionalChainingParam = variable.name;
383
+ break;
384
+ }
385
+ }
386
+ current = p;
387
+ }
388
+ }
389
+ }
390
+ }
391
+
392
+ const hasParameterReassignment =
393
+ Object.keys(parameterReassignmentInfo).length > 0;
394
+
395
+ const simpleReassignments = [];
396
+ if (
397
+ hasParameterReassignment &&
398
+ !hasParameterInSpread &&
399
+ functionNode.body.body &&
400
+ functionNode.body.body.length > 0
401
+ ) {
402
+ for (let i = 0; i < functionNode.body.body.length; i++) {
403
+ const statement = functionNode.body.body[i];
404
+
405
+ // 1. Handle direct top-level assignment: foo = foo || [];
406
+ if (
407
+ statement.type === "ExpressionStatement" &&
408
+ statement.expression.type === "AssignmentExpression" &&
409
+ statement.expression.left.type === "Identifier" &&
410
+ paramNames.includes(statement.expression.left.name)
411
+ ) {
412
+ const paramName = statement.expression.left.name;
413
+ const paramInfo = parameterReassignmentInfo[paramName];
414
+ if (paramInfo && !paramInfo.hasUpdateExpression) {
415
+ const firstAssignment = paramInfo.assignments[0];
416
+ if (
417
+ firstAssignment &&
418
+ firstAssignment.depth === 0 &&
419
+ firstAssignment.node === statement.expression
420
+ ) {
421
+ simpleReassignments.push({
422
+ statement,
423
+ paramName,
424
+ info: paramInfo,
425
+ });
426
+ continue;
427
+ }
428
+ }
429
+ }
430
+
431
+ // 2. Handle simple guard clause: if (!foo) { foo = []; }
432
+ if (
433
+ statement.type === "IfStatement" &&
434
+ statement.consequent.type === "BlockStatement" &&
435
+ statement.consequent.body.length === 1 &&
436
+ statement.consequent.body[0].type === "ExpressionStatement" &&
437
+ statement.consequent.body[0].expression.type ===
438
+ "AssignmentExpression" &&
439
+ statement.consequent.body[0].expression.left.type === "Identifier" &&
440
+ paramNames.includes(statement.consequent.body[0].expression.left.name)
441
+ ) {
442
+ // Check if the test is a "null check"
443
+ const test = statement.test;
444
+ const isUnaryNegation =
445
+ test.type === "UnaryExpression" &&
446
+ test.operator === "!" &&
447
+ test.argument.type === "Identifier" &&
448
+ paramNames.includes(test.argument.name);
449
+
450
+ const isBinaryNullCheck =
451
+ test.type === "BinaryExpression" &&
452
+ (test.operator === "==" ||
453
+ test.operator === "===" ||
454
+ test.operator === "!=" ||
455
+ test.operator === "!==") &&
456
+ ((test.left.type === "Identifier" &&
457
+ paramNames.includes(test.left.name) &&
458
+ (test.right.type === "Literal" ||
459
+ (test.right.type === "Identifier" &&
460
+ test.right.name === "undefined"))) ||
461
+ (test.right.type === "Identifier" &&
462
+ paramNames.includes(test.right.name) &&
463
+ (test.left.type === "Literal" ||
464
+ (test.left.type === "Identifier" &&
465
+ test.left.name === "undefined"))));
466
+
467
+ if (isUnaryNegation || isBinaryNullCheck) {
468
+ const assignment = statement.consequent.body[0].expression;
469
+ const paramName = assignment.left.name;
470
+ const paramInfo = parameterReassignmentInfo[paramName];
471
+
472
+ if (
473
+ paramInfo &&
474
+ !paramInfo.hasUpdateExpression &&
475
+ paramInfo.assignments.length === 1 &&
476
+ paramInfo.assignments[0].depth === 1 &&
477
+ paramInfo.assignments[0].node === assignment
478
+ ) {
479
+ simpleReassignments.push({
480
+ statement,
481
+ paramName,
482
+ info: paramInfo,
483
+ isGuard: true,
484
+ });
485
+ continue;
486
+ }
487
+ }
488
+ }
489
+
490
+ break;
491
+ }
492
+ }
493
+
494
+ const reportData = { name: discourseComputedLocalName };
495
+ const hasSimpleReassignments = simpleReassignments.length > 0;
496
+
497
+ if (hasParameterInNestedFunction) {
498
+ const idx = paramNames.indexOf(nestedFunctionParam);
499
+ return {
500
+ canAutoFix: false,
501
+ messageId: "cannotAutoFixNestedFunction",
502
+ reportData: {
503
+ ...reportData,
504
+ param: nestedFunctionParam,
505
+ propertyPath: decoratorArgs[idx] || nestedFunctionParam,
506
+ },
507
+ };
508
+ }
509
+
510
+ if (hasUnsafeOptionalChaining) {
511
+ const idx = paramNames.indexOf(unsafeOptionalChainingParam);
512
+ return {
513
+ canAutoFix: false,
514
+ messageId: "cannotAutoFixUnsafeOptionalChaining",
515
+ reportData: {
516
+ ...reportData,
517
+ param: unsafeOptionalChainingParam,
518
+ propertyPath: decoratorArgs[idx] || unsafeOptionalChainingParam,
519
+ },
520
+ };
521
+ }
522
+
523
+ if (hasParameterInSpread) {
524
+ const idx = paramNames.indexOf(spreadParam);
525
+ return {
526
+ canAutoFix: false,
527
+ messageId: "cannotAutoFixSpread",
528
+ reportData: {
529
+ ...reportData,
530
+ param: spreadParam,
531
+ propertyPath: decoratorArgs[idx] || spreadParam,
532
+ },
533
+ };
534
+ }
535
+
536
+ if (hasParameterReassignment && !hasSimpleReassignments) {
537
+ const reassignedParam = Object.keys(parameterReassignmentInfo)[0];
538
+ const reassignedInfo = parameterReassignmentInfo[reassignedParam] || {};
539
+ const idx = paramNames.indexOf(reassignedParam);
540
+ const propertyPath = decoratorArgs[idx] || reassignedParam;
541
+
542
+ let messageId = "cannotAutoFixGeneric";
543
+ if (reassignedInfo.hasUpdateExpression) {
544
+ messageId = "cannotAutoFixUpdateExpression";
545
+ } else if (
546
+ reassignedInfo.assignments &&
547
+ reassignedInfo.assignments.length > 0 &&
548
+ reassignedInfo.assignments[0].depth > 0
549
+ ) {
550
+ messageId = "cannotAutoFixNestedReassignment";
551
+ }
552
+
553
+ return {
554
+ canAutoFix: false,
555
+ messageId,
556
+ reportData: { ...reportData, param: reassignedParam, propertyPath },
557
+ };
558
+ }
559
+
560
+ return {
561
+ canAutoFix: true,
562
+ simpleReassignments,
563
+ reportData: { name: discourseComputedLocalName },
564
+ };
565
+ }
566
+
567
+ function analyzeObjectExpression(objExpr, infoObj) {
568
+ if (!objExpr || !objExpr.properties) {
569
+ return;
570
+ }
571
+
572
+ objExpr.properties.forEach((prop) => {
573
+ if (
574
+ prop.type === "Property" &&
575
+ prop.decorators &&
576
+ prop.decorators.length > 0
577
+ ) {
578
+ const discourseDecorator = prop.decorators.find((decorator) => {
579
+ const expr = decorator.expression;
580
+ if (expr.type === "CallExpression") {
581
+ return expr.callee.name === discourseComputedLocalName;
582
+ }
583
+ return expr.name === discourseComputedLocalName;
584
+ });
585
+
586
+ if (discourseDecorator) {
587
+ infoObj.hasClassicClassDecorators = true;
588
+ infoObj.usageMap.set(discourseDecorator, {
589
+ canAutoFix: false,
590
+ isClassic: true,
591
+ messageId: "cannotAutoFixClassic",
592
+ reportData: { name: discourseComputedLocalName },
593
+ });
594
+ }
595
+ }
596
+
597
+ if (
598
+ prop.type === "Property" &&
599
+ prop.value &&
600
+ prop.value.type === "CallExpression" &&
601
+ prop.value.callee &&
602
+ prop.value.callee.name === discourseComputedLocalName
603
+ ) {
604
+ infoObj.hasClassicClassDecorators = true;
605
+ infoObj.usageMap.set(prop.value, {
606
+ canAutoFix: false,
607
+ isClassic: true,
608
+ messageId: "cannotAutoFixClassic",
609
+ reportData: { name: discourseComputedLocalName },
610
+ });
611
+ }
612
+ });
613
+ }
614
+ }