@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.
- package/eslint-rules/no-discourse-computed/discourse-computed-analysis.mjs +614 -0
- package/eslint-rules/no-discourse-computed/discourse-computed-fixer.mjs +319 -0
- package/eslint-rules/no-discourse-computed.mjs +388 -0
- package/eslint-rules/utils/analyze-imports.mjs +65 -0
- package/eslint-rules/utils/property-path.mjs +78 -0
- package/eslint.mjs +3 -0
- package/package.json +5 -5
|
@@ -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
|
+
};
|