@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,319 @@
1
+ import { propertyPathToOptionalChaining } from "../utils/property-path.mjs";
2
+
3
+ /**
4
+ * Create a fixer function that converts a method with parameters into a getter
5
+ * that reads from this.<property>. The fixer will:
6
+ * - replace the decorator callee with the computed import name
7
+ * - insert the `get ` keyword before the method name if absent
8
+ * - remove the parameter list from the method signature
9
+ * - replace identifier occurrences of parameters with `this.<path>` accesses
10
+ *
11
+ * @param {import('eslint').SourceCode} sourceCode
12
+ * @param {import('estree').MethodDefinition} node
13
+ * @param {string[]} decoratorArgs
14
+ * @param {string} computedImportName
15
+ * @param {{simpleReassignments?: Array}} [options]
16
+ * @returns {(fixer: import('eslint').Rule.RuleFixer) => import('eslint').Rule.Fix[]}
17
+ */
18
+ export function createMethodFix(
19
+ sourceCode,
20
+ node,
21
+ decoratorArgs,
22
+ computedImportName,
23
+ options = {}
24
+ ) {
25
+ return function (fixer) {
26
+ const fixes = [];
27
+
28
+ // replace decorator callee/name
29
+ const deco = node.decorators && node.decorators.find((d) => d.expression);
30
+ const decoratorExpression = deco && deco.expression;
31
+ if (decoratorExpression) {
32
+ if (decoratorExpression.type === "CallExpression") {
33
+ fixes.push(
34
+ fixer.replaceText(decoratorExpression.callee, computedImportName)
35
+ );
36
+ } else {
37
+ fixes.push(fixer.replaceText(decoratorExpression, computedImportName));
38
+ }
39
+ }
40
+
41
+ const methodKey = node.key;
42
+ const hasParams = node.value.params && node.value.params.length > 0;
43
+ const paramNames = node.value.params
44
+ ? node.value.params.map((p) => p.name)
45
+ : [];
46
+
47
+ if (node.kind !== "get") {
48
+ fixes.push(fixer.insertTextBefore(methodKey, "get "));
49
+ }
50
+
51
+ if (hasParams) {
52
+ const paramsStart = node.value.params[0].range[0];
53
+ const paramsEnd =
54
+ node.value.params[node.value.params.length - 1].range[1];
55
+ fixes.push(fixer.removeRange([paramsStart, paramsEnd]));
56
+
57
+ const paramToProperty = {};
58
+ paramNames.forEach((name, i) => {
59
+ paramToProperty[name] = decoratorArgs[i] || name;
60
+ });
61
+
62
+ // Small expression replacer used for simple-reassignment right-hand sides
63
+ const replaceIdentifiersInExpression = (expr) => {
64
+ if (!expr || typeof expr !== "object") {
65
+ return sourceCode.getText(expr);
66
+ }
67
+ if (expr.type === "Identifier") {
68
+ if (paramToProperty[expr.name]) {
69
+ return propertyPathToOptionalChaining(
70
+ paramToProperty[expr.name],
71
+ true,
72
+ false
73
+ );
74
+ }
75
+ return expr.name;
76
+ }
77
+ if (expr.type === "CallExpression") {
78
+ const callee = replaceIdentifiersInExpression(expr.callee);
79
+ const args = expr.arguments
80
+ .map((a) => replaceIdentifiersInExpression(a))
81
+ .join(", ");
82
+ return `${callee}(${args})`;
83
+ }
84
+ if (expr.type === "MemberExpression") {
85
+ const object = replaceIdentifiersInExpression(expr.object);
86
+ if (expr.computed) {
87
+ const property = replaceIdentifiersInExpression(expr.property);
88
+ return `${object}[${property}]`;
89
+ }
90
+ return `${object}.${expr.property.name}`;
91
+ }
92
+ if (expr.type === "ArrayExpression") {
93
+ return `[${expr.elements.map((el) => (el ? replaceIdentifiersInExpression(el) : "")).join(", ")}]`;
94
+ }
95
+ if (expr.type === "ObjectExpression") {
96
+ return sourceCode.getText(expr);
97
+ }
98
+ if (
99
+ expr.type === "LogicalExpression" ||
100
+ expr.type === "BinaryExpression"
101
+ ) {
102
+ return `${replaceIdentifiersInExpression(expr.left)} ${expr.operator} ${replaceIdentifiersInExpression(expr.right)}`;
103
+ }
104
+ if (expr.type === "ConditionalExpression") {
105
+ return `${replaceIdentifiersInExpression(expr.test)} ? ${replaceIdentifiersInExpression(expr.consequent)} : ${replaceIdentifiersInExpression(expr.alternate)}`;
106
+ }
107
+ return sourceCode.getText(expr);
108
+ };
109
+
110
+ // Collect replacements for identifier occurrences
111
+ const replacements = [];
112
+
113
+ // Apply simple reassignments first if provided (these were detected earlier).
114
+ const simpleReassignments = options.simpleReassignments || [];
115
+ const handledRanges = [];
116
+
117
+ if (simpleReassignments.length > 0) {
118
+ for (const reassignment of simpleReassignments) {
119
+ const { statement, paramName, info, isGuard } = reassignment;
120
+
121
+ if (isGuard) {
122
+ // Guard clause: if (!foo) { foo = []; }
123
+ const assignmentExpr = statement.consequent.body[0].expression;
124
+ const access = propertyPathToOptionalChaining(
125
+ paramToProperty[paramName],
126
+ true,
127
+ false
128
+ );
129
+ const newRight = replaceIdentifiersInExpression(
130
+ assignmentExpr.right
131
+ );
132
+ // Example: let foo = this.foo || [];
133
+ // We use 'let' because it was inside an 'if', implying it might be reassigned
134
+ // (though our analysis ensures it's the ONLY assignment in simple cases)
135
+ replacements.push({
136
+ range: statement.range,
137
+ text: `let ${paramName} = ${access} || ${newRight};`,
138
+ });
139
+ } else {
140
+ // Direct assignment: foo = foo || [];
141
+ const assignmentExpr = statement.expression;
142
+ const useConst = info.assignments.length === 1;
143
+ const keyword = useConst ? "const" : "let";
144
+ const newRight = replaceIdentifiersInExpression(
145
+ assignmentExpr.right
146
+ );
147
+ replacements.push({
148
+ range: statement.range,
149
+ text: `${keyword} ${paramName} = ${newRight};`,
150
+ });
151
+ }
152
+ handledRanges.push(statement.range);
153
+ // prevent further replacements for this param
154
+ delete paramToProperty[paramName];
155
+ }
156
+ }
157
+
158
+ // Use ESLint scope analysis to find all references to parameters
159
+ const scope = sourceCode.getScope(node.value);
160
+ for (const variable of scope.variables) {
161
+ if (!paramToProperty[variable.name] || variable.scope !== scope) {
162
+ continue;
163
+ }
164
+
165
+ const propertyPath = paramToProperty[variable.name];
166
+
167
+ for (const reference of variable.references) {
168
+ const refNode = reference.identifier;
169
+
170
+ // Skip if this reference is inside a range already handled by simpleReassignments
171
+ if (
172
+ handledRanges.some(
173
+ (range) =>
174
+ refNode.range[0] >= range[0] && refNode.range[1] <= range[1]
175
+ )
176
+ ) {
177
+ continue;
178
+ }
179
+
180
+ const parent = refNode.parent;
181
+
182
+ // contexts to skip
183
+ if (
184
+ parent &&
185
+ parent.type === "Property" &&
186
+ parent.key === refNode &&
187
+ !parent.shorthand
188
+ ) {
189
+ continue;
190
+ }
191
+ if (
192
+ parent &&
193
+ parent.type === "Property" &&
194
+ parent.key === refNode &&
195
+ parent.value !== refNode
196
+ ) {
197
+ continue;
198
+ }
199
+ if (
200
+ parent &&
201
+ parent.type === "MemberExpression" &&
202
+ parent.property === refNode &&
203
+ !parent.computed
204
+ ) {
205
+ continue;
206
+ }
207
+ if (reference.isWrite()) {
208
+ continue;
209
+ }
210
+
211
+ // shorthand property: { foo } -> { foo: this.foo }
212
+ if (parent && parent.type === "Property" && parent.shorthand) {
213
+ const access = propertyPathToOptionalChaining(
214
+ propertyPath,
215
+ true,
216
+ false
217
+ );
218
+ replacements.push({
219
+ range: refNode.range,
220
+ text: `${refNode.name}: ${access}`,
221
+ });
222
+ continue;
223
+ }
224
+
225
+ const isInMemberExpression =
226
+ parent &&
227
+ parent.type === "MemberExpression" &&
228
+ parent.object === refNode;
229
+
230
+ const access = propertyPathToOptionalChaining(
231
+ propertyPath,
232
+ true,
233
+ isInMemberExpression
234
+ );
235
+
236
+ if (isInMemberExpression && access.endsWith("?.")) {
237
+ // Replace identifier with access without trailing '?.'
238
+ replacements.push({
239
+ range: refNode.range,
240
+ text: access.slice(0, -2),
241
+ });
242
+
243
+ // adjust following punctuation to avoid '?..' (replace '.' with '?.' or insert '?.' before '[')
244
+ const fullText = sourceCode.getText();
245
+ let pos = refNode.range[1];
246
+ while (pos < fullText.length && /\s/.test(fullText.charAt(pos))) {
247
+ pos++;
248
+ }
249
+ const ch = fullText.charAt(pos);
250
+ const nextCh = fullText.charAt(pos + 1);
251
+
252
+ if (ch === "?" && nextCh === ".") {
253
+ // already '?.' — nothing
254
+ } else if (ch === ".") {
255
+ replacements.push({ range: [pos, pos + 1], text: "?." });
256
+ } else if (ch === "[") {
257
+ replacements.push({ range: [pos, pos], text: "?." });
258
+ } else if (ch === "\n") {
259
+ // Multiline member expression: prefer replacing the '.' at the start of the next
260
+ // non-whitespace character with '?.' so lines like '\n .filter' become '\n ?.filter'.
261
+ let insertPos = pos;
262
+ while (
263
+ insertPos < fullText.length &&
264
+ /\s/.test(fullText.charAt(insertPos))
265
+ ) {
266
+ insertPos++;
267
+ }
268
+ const nextChar = fullText.charAt(insertPos);
269
+ if (nextChar === ".") {
270
+ replacements.push({
271
+ range: [insertPos, insertPos + 1],
272
+ text: "?.",
273
+ });
274
+ } else {
275
+ // Fallback: insert '?.' at the computed position
276
+ replacements.push({
277
+ range: [insertPos, insertPos],
278
+ text: "?.",
279
+ });
280
+ }
281
+ }
282
+
283
+ // Additionally, propagate optional chaining to chained call/member sequences.
284
+ let ancestor = parent.parent;
285
+ while (ancestor) {
286
+ if (
287
+ ancestor.type === "CallExpression" &&
288
+ ancestor.parent &&
289
+ ancestor.parent.type === "MemberExpression"
290
+ ) {
291
+ const afterCallPos = ancestor.range[1];
292
+ let p = afterCallPos;
293
+ while (p < fullText.length && /\s/.test(fullText.charAt(p))) {
294
+ p++;
295
+ }
296
+ if (fullText.charAt(p) === ".") {
297
+ replacements.push({ range: [p, p + 1], text: "?." });
298
+ }
299
+ ancestor = ancestor.parent.parent;
300
+ continue;
301
+ }
302
+ break;
303
+ }
304
+ } else {
305
+ replacements.push({ range: refNode.range, text: access });
306
+ }
307
+ }
308
+ }
309
+
310
+ // Apply replacements from end to start in a single pass so fixes don't overlap
311
+ replacements.sort((a, b) => b.range[0] - a.range[0]);
312
+ for (const r of replacements) {
313
+ fixes.push(fixer.replaceTextRange(r.range, r.text));
314
+ }
315
+ }
316
+
317
+ return fixes;
318
+ };
319
+ }
@@ -0,0 +1,388 @@
1
+ import { analyzeDiscourseComputedUsage as analyzeDiscourseComputedUsageUtil } from "./no-discourse-computed/discourse-computed-analysis.mjs";
2
+ import { createMethodFix } from "./no-discourse-computed/discourse-computed-fixer.mjs";
3
+ import {
4
+ collectImports,
5
+ getImportedLocalNames,
6
+ } from "./utils/analyze-imports.mjs";
7
+ import { fixImport } from "./utils/fix-import.mjs";
8
+
9
+ const USE_COMPUTED_INSTEAD = "Use `@computed` instead of `@{{name}}`";
10
+
11
+ /**
12
+ * Fixer for the discourseComputed import.
13
+ * Handles removing or modifying the import based on whether all usages were converted.
14
+ */
15
+ function fixDiscourseImport(
16
+ fixer,
17
+ importNode,
18
+ usageInfo,
19
+ sourceCode,
20
+ hasComputedImport,
21
+ emberObjectImportNode,
22
+ computedImportName,
23
+ discourseComputedLocalName
24
+ ) {
25
+ const fixes = [];
26
+ const {
27
+ hasFixableDecorators,
28
+ hasClassicClassDecorators,
29
+ hasParameterReassignments,
30
+ hasParametersInSpread,
31
+ hasUnsafeOptionalChaining,
32
+ hasParameterInNestedFunction,
33
+ } = usageInfo;
34
+
35
+ // Only provide fixes if there are fixable decorators
36
+ if (!hasFixableDecorators) {
37
+ return fixes;
38
+ }
39
+
40
+ // Check if there are other named imports in discourse/lib/decorators
41
+ const namedSpecifiers = importNode.specifiers.filter(
42
+ (spec) => spec.type === "ImportSpecifier"
43
+ );
44
+
45
+ // Determine the import string to use (with alias if needed)
46
+ const computedImportString =
47
+ computedImportName === "computed"
48
+ ? "computed"
49
+ : `computed as ${computedImportName}`;
50
+
51
+ // If there are no classic class decorators or non-fixable decorators remaining,
52
+ // we can potentially remove the discourseComputed import.
53
+ const hasRemainingUsage =
54
+ hasClassicClassDecorators ||
55
+ hasParameterReassignments ||
56
+ hasParametersInSpread ||
57
+ hasUnsafeOptionalChaining ||
58
+ hasParameterInNestedFunction;
59
+
60
+ if (!hasRemainingUsage) {
61
+ if (namedSpecifiers.length > 0) {
62
+ // Keep named imports, remove default import
63
+ fixes.push(
64
+ fixImport(fixer, importNode, {
65
+ defaultImport: false,
66
+ })
67
+ );
68
+
69
+ // Add computed to @ember/object import (if not already present)
70
+ if (!hasComputedImport) {
71
+ if (emberObjectImportNode) {
72
+ fixes.push(
73
+ fixImport(fixer, emberObjectImportNode, {
74
+ namedImportsToAdd: [computedImportString],
75
+ })
76
+ );
77
+ } else {
78
+ fixes.push(
79
+ fixer.insertTextAfter(
80
+ importNode,
81
+ `\nimport { ${computedImportString} } from "@ember/object";`
82
+ )
83
+ );
84
+ }
85
+ }
86
+ } else {
87
+ // No named imports, handle entire import line removal or replacement
88
+ if (!hasComputedImport) {
89
+ if (emberObjectImportNode) {
90
+ // Remove discourseComputed import, add computed to @ember/object
91
+ const nextChar = sourceCode.getText().charAt(importNode.range[1]);
92
+ const rangeEnd =
93
+ nextChar === "\n" ? importNode.range[1] + 1 : importNode.range[1];
94
+ fixes.push(fixer.removeRange([importNode.range[0], rangeEnd]));
95
+
96
+ fixes.push(
97
+ fixImport(fixer, emberObjectImportNode, {
98
+ namedImportsToAdd: [computedImportString],
99
+ })
100
+ );
101
+ } else {
102
+ // Replace discourseComputed import with @ember/object import
103
+ fixes.push(
104
+ fixer.replaceText(
105
+ importNode,
106
+ `import { ${computedImportString} } from "@ember/object";`
107
+ )
108
+ );
109
+ }
110
+ } else {
111
+ // computed already imported, just remove discourseComputed
112
+ const nextChar = sourceCode.getText().charAt(importNode.range[1]);
113
+ const rangeEnd =
114
+ nextChar === "\n" ? importNode.range[1] + 1 : importNode.range[1];
115
+ fixes.push(fixer.removeRange([importNode.range[0], rangeEnd]));
116
+ }
117
+ }
118
+ } else {
119
+ // Has remaining usages, keep discourseComputed import but add computed for fixable ones
120
+
121
+ // If the default import is named 'computed', rename it to 'discourseComputed' to avoid conflict
122
+ if (discourseComputedLocalName === "computed") {
123
+ const namedImportStrings = namedSpecifiers.map((spec) => {
124
+ if (spec.imported.name === spec.local.name) {
125
+ return spec.imported.name;
126
+ } else {
127
+ return `${spec.imported.name} as ${spec.local.name}`;
128
+ }
129
+ });
130
+
131
+ let newImportStatement = "import discourseComputed";
132
+ if (namedImportStrings.length > 0) {
133
+ newImportStatement += `, { ${namedImportStrings.join(", ")} }`;
134
+ }
135
+ newImportStatement += ` from "${importNode.source.value}";`;
136
+
137
+ fixes.push(fixer.replaceText(importNode, newImportStatement));
138
+ }
139
+
140
+ if (!hasComputedImport) {
141
+ if (emberObjectImportNode) {
142
+ fixes.push(
143
+ fixImport(fixer, emberObjectImportNode, {
144
+ namedImportsToAdd: [computedImportString],
145
+ })
146
+ );
147
+ } else {
148
+ fixes.push(
149
+ fixer.insertTextAfter(
150
+ importNode,
151
+ `\nimport { ${computedImportString} } from "@ember/object";`
152
+ )
153
+ );
154
+ }
155
+ }
156
+ }
157
+
158
+ return fixes;
159
+ }
160
+
161
+ export default {
162
+ meta: {
163
+ type: "suggestion",
164
+ docs: {
165
+ description:
166
+ "Convert @discourseComputed decorators to native Ember @computed",
167
+ },
168
+ fixable: "code",
169
+ schema: [], // no options
170
+ messages: {
171
+ replaceImport:
172
+ 'Use `import { computed } from "@ember/object";` instead of `import {{name}} from "discourse/lib/decorators";`.',
173
+ replaceDecorator: `${USE_COMPUTED_INSTEAD}.`,
174
+ cannotAutoFixClassic: `${USE_COMPUTED_INSTEAD}: convert this classic Ember class to a native ES6 class first.`,
175
+ cannotAutoFixNestedFunction: `${USE_COMPUTED_INSTEAD}: replace \`{{param}}\` manually with \`this.{{propertyPath}}\` — it's referenced inside a nested function.`,
176
+ cannotAutoFixUnsafeOptionalChaining: `${USE_COMPUTED_INSTEAD}: replace \`{{param}}\` manually with \`this.{{propertyPath}}\` — automatic optional chaining would be unsafe (e.g. use \`(this.{{propertyPath}} || "").someMethod()\`).`,
177
+ cannotAutoFixUpdateExpression: `${USE_COMPUTED_INSTEAD}: \`{{param}}\` uses update expressions (++/--). Convert manually using a local variable (e.g. \`let {{param}} = this.{{propertyPath}}; {{param}}++;\`).`,
178
+ cannotAutoFixNestedReassignment: `${USE_COMPUTED_INSTEAD}: \`{{param}}\` is reassigned inside a nested block. Convert manually using a local variable (e.g. \`let {{param}} = this.{{propertyPath}}; if (...) { {{param}} = ... }\`).`,
179
+ cannotAutoFixSpread: `${USE_COMPUTED_INSTEAD}: \`{{param}}\` is used in a spread. Convert manually (e.g. \`...(this.{{propertyPath}} || [])\`).`,
180
+ cannotAutoFixArguments: `${USE_COMPUTED_INSTEAD}: replace \`...arguments\` with explicit \`this.propertyName\` access.`,
181
+ cannotAutoFixGeneric: `${USE_COMPUTED_INSTEAD}: replace \`{{param}}\` manually with \`this.{{propertyPath}}\`.`,
182
+ },
183
+ },
184
+
185
+ create(context) {
186
+ const sourceCode = context.getSourceCode();
187
+ let hasComputedImport = false;
188
+ let emberObjectImportNode = null;
189
+ let discourseComputedInfo = null; // Cache info about discourseComputed decorators
190
+ let discourseComputedLocalName = null; // Track the local name used for discourseComputed import
191
+ let computedImportName = null; // Track what name to use for computed from @ember/object
192
+ let importsAnalyzed = false; // Track if we've scanned all imports
193
+
194
+ // We now use utilities in ./utils to keep this file focused on the rule logic.
195
+
196
+ // Wrapper that uses the generic helpers to populate rule-specific import state.
197
+ // We intentionally compute the specific values here (instead of in the utils
198
+ // module) so `utils/analyze-imports.mjs` remains generic and reusable.
199
+ function analyzeAllDiscourseComputedImports() {
200
+ if (importsAnalyzed) {
201
+ return;
202
+ }
203
+
204
+ const imports = collectImports(sourceCode);
205
+ const allImportedIdentifiers = getImportedLocalNames(sourceCode);
206
+
207
+ // @ember/object import
208
+ const emberNode = imports.get("@ember/object");
209
+ if (emberNode) {
210
+ emberObjectImportNode = emberNode.node;
211
+ const computedSpecifier = emberNode.specifiers.find(
212
+ (spec) =>
213
+ spec.type === "ImportSpecifier" &&
214
+ spec.imported &&
215
+ spec.imported.name === "computed"
216
+ );
217
+ if (computedSpecifier) {
218
+ hasComputedImport = true;
219
+ computedImportName = computedSpecifier.local.name;
220
+ }
221
+ }
222
+
223
+ // discourse default import
224
+ const discourseNode = imports.get("discourse/lib/decorators");
225
+ if (discourseNode) {
226
+ const defaultSpecifier = discourseNode.specifiers.find(
227
+ (spec) => spec.type === "ImportDefaultSpecifier"
228
+ );
229
+ if (defaultSpecifier) {
230
+ discourseComputedLocalName = defaultSpecifier.local.name;
231
+ }
232
+ }
233
+
234
+ if (!computedImportName) {
235
+ const isComputedUsedElsewhere =
236
+ allImportedIdentifiers.has("computed") &&
237
+ discourseComputedLocalName !== "computed";
238
+ computedImportName = isComputedUsedElsewhere
239
+ ? "emberComputed"
240
+ : "computed";
241
+ }
242
+
243
+ importsAnalyzed = true;
244
+ }
245
+
246
+ function analyzeDiscourseComputedUsage() {
247
+ if (discourseComputedInfo !== null) {
248
+ return discourseComputedInfo;
249
+ }
250
+ // Delegate to the reusable analyzer; keep result cached locally
251
+ discourseComputedInfo = analyzeDiscourseComputedUsageUtil(
252
+ sourceCode,
253
+ discourseComputedLocalName
254
+ );
255
+ return discourseComputedInfo;
256
+ }
257
+
258
+ return {
259
+ ImportDeclaration(node) {
260
+ // Analyze all imports first to avoid race conditions
261
+ analyzeAllDiscourseComputedImports();
262
+
263
+ // Handle import from "discourse/lib/decorators"
264
+ // The default export is discourseComputed, but it could be imported with any name
265
+ if (node.source.value === "discourse/lib/decorators") {
266
+ const defaultSpecifier = node.specifiers.find(
267
+ (spec) => spec.type === "ImportDefaultSpecifier"
268
+ );
269
+
270
+ if (defaultSpecifier) {
271
+ const usageInfo = analyzeDiscourseComputedUsage();
272
+
273
+ context.report({
274
+ node: defaultSpecifier,
275
+ messageId: "replaceImport",
276
+ data: { name: discourseComputedLocalName },
277
+ fix: (fixer) =>
278
+ fixDiscourseImport(
279
+ fixer,
280
+ node,
281
+ usageInfo,
282
+ sourceCode,
283
+ hasComputedImport,
284
+ emberObjectImportNode,
285
+ computedImportName,
286
+ discourseComputedLocalName
287
+ ),
288
+ });
289
+ }
290
+ }
291
+ },
292
+
293
+ CallExpression(node) {
294
+ const { usageMap } = analyzeDiscourseComputedUsage();
295
+ const usage = usageMap.get(node);
296
+ if (usage) {
297
+ context.report({
298
+ node,
299
+ messageId: usage.messageId,
300
+ data: usage.reportData,
301
+ });
302
+ }
303
+ },
304
+
305
+ Property: function (node) {
306
+ const { usageMap } = analyzeDiscourseComputedUsage();
307
+ const discourseDecorator = (node.decorators || []).find((decorator) =>
308
+ usageMap.has(decorator)
309
+ );
310
+
311
+ if (discourseDecorator) {
312
+ const usage = usageMap.get(discourseDecorator);
313
+ context.report({
314
+ node: discourseDecorator,
315
+ messageId: usage.messageId,
316
+ data: usage.reportData,
317
+ });
318
+ }
319
+ },
320
+
321
+ MethodDefinition: function (node) {
322
+ if (!node.decorators || node.decorators.length === 0) {
323
+ return;
324
+ }
325
+
326
+ const { usageMap, hasParameterReassignments } =
327
+ analyzeDiscourseComputedUsage();
328
+ const discourseComputedDecorator = node.decorators.find((decorator) =>
329
+ usageMap.has(decorator)
330
+ );
331
+
332
+ if (!discourseComputedDecorator) {
333
+ return;
334
+ }
335
+
336
+ const usage = usageMap.get(discourseComputedDecorator);
337
+ const { canAutoFix, messageId, reportData, simpleReassignments } =
338
+ usage;
339
+
340
+ // Determine if we need to rename non-fixable decorators
341
+ // This happens when: import was originally named 'computed', we're keeping it (mixed scenario),
342
+ // and we renamed it to 'discourseComputed'
343
+ const needsDecoratorRename =
344
+ !canAutoFix &&
345
+ hasParameterReassignments &&
346
+ discourseComputedLocalName === "computed";
347
+
348
+ const decoratorExpression = discourseComputedDecorator.expression;
349
+ let decoratorArgs = [];
350
+ if (decoratorExpression.type === "CallExpression") {
351
+ decoratorArgs = decoratorExpression.arguments
352
+ .map((arg) => (arg.type === "Literal" ? arg.value : null))
353
+ .filter(Boolean);
354
+ }
355
+
356
+ context.report({
357
+ node: discourseComputedDecorator,
358
+ messageId: messageId || "replaceDecorator",
359
+ data: reportData,
360
+ fix: !canAutoFix
361
+ ? needsDecoratorRename
362
+ ? function (fixer) {
363
+ // Just rename the decorator to match the renamed import
364
+ if (decoratorExpression.type === "CallExpression") {
365
+ return fixer.replaceText(
366
+ decoratorExpression.callee,
367
+ "discourseComputed"
368
+ );
369
+ } else {
370
+ return fixer.replaceText(
371
+ decoratorExpression,
372
+ "discourseComputed"
373
+ );
374
+ }
375
+ }
376
+ : undefined
377
+ : createMethodFix(
378
+ sourceCode,
379
+ node,
380
+ decoratorArgs,
381
+ computedImportName,
382
+ { simpleReassignments }
383
+ ),
384
+ });
385
+ },
386
+ };
387
+ },
388
+ };