@discourse/lint-configs 2.42.0 → 2.44.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.
|
@@ -11,7 +11,7 @@ export default {
|
|
|
11
11
|
create(context) {
|
|
12
12
|
return {
|
|
13
13
|
ImportDeclaration(node) {
|
|
14
|
-
function denyImporting(symbolName, messageTemplate) {
|
|
14
|
+
function denyImporting(symbolName, messageTemplate, fixFn) {
|
|
15
15
|
const specifier = node.specifiers.find(
|
|
16
16
|
(spec) => spec.imported && spec.imported.name === symbolName
|
|
17
17
|
);
|
|
@@ -20,6 +20,7 @@ export default {
|
|
|
20
20
|
context.report({
|
|
21
21
|
node: specifier,
|
|
22
22
|
message: messageTemplate(symbolName),
|
|
23
|
+
fix: fixFn ? (fixer) => fixFn(fixer, specifier) : undefined,
|
|
23
24
|
});
|
|
24
25
|
}
|
|
25
26
|
}
|
|
@@ -69,7 +70,7 @@ export default {
|
|
|
69
70
|
fix(fixer) {
|
|
70
71
|
return fixer.replaceText(
|
|
71
72
|
node,
|
|
72
|
-
`import {
|
|
73
|
+
`import { trustHTML } from "@ember/template";`
|
|
73
74
|
);
|
|
74
75
|
},
|
|
75
76
|
});
|
|
@@ -88,6 +89,38 @@ export default {
|
|
|
88
89
|
);
|
|
89
90
|
},
|
|
90
91
|
});
|
|
92
|
+
} else if (node.source.value === "@ember/template") {
|
|
93
|
+
denyImporting(
|
|
94
|
+
"htmlSafe",
|
|
95
|
+
() =>
|
|
96
|
+
"'htmlSafe' is deprecated. Use 'trustHTML' from '@ember/template' instead.",
|
|
97
|
+
(fixer, specifier) => {
|
|
98
|
+
const fixes = [
|
|
99
|
+
fixer.replaceText(specifier.imported, "trustHTML"),
|
|
100
|
+
];
|
|
101
|
+
|
|
102
|
+
if (specifier.local.name === specifier.imported.name) {
|
|
103
|
+
const moduleScope = context.sourceCode.scopeManager.scopes.find(
|
|
104
|
+
(s) => s.type === "module"
|
|
105
|
+
);
|
|
106
|
+
const variable = moduleScope?.variables.find(
|
|
107
|
+
(v) => v.name === specifier.local.name
|
|
108
|
+
);
|
|
109
|
+
|
|
110
|
+
if (variable) {
|
|
111
|
+
for (const ref of variable.references) {
|
|
112
|
+
if (ref.identifier !== specifier.local) {
|
|
113
|
+
fixes.push(
|
|
114
|
+
fixer.replaceText(ref.identifier, "trustHTML")
|
|
115
|
+
);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
}
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
return fixes;
|
|
122
|
+
}
|
|
123
|
+
);
|
|
91
124
|
} else if (node.source.value === "@ember/array") {
|
|
92
125
|
const messageTemplate = (symbolName) =>
|
|
93
126
|
`Importing '${symbolName}' from '@ember/array' is deprecated. Use tracked arrays or native JavaScript arrays instead.`;
|
|
@@ -107,6 +140,38 @@ export default {
|
|
|
107
140
|
denyDefaultImport(
|
|
108
141
|
"Importing ArrayProxy (default) from '@ember/array/proxy' is deprecated. Use tracked arrays or native JavaScript arrays instead."
|
|
109
142
|
);
|
|
143
|
+
} else if (node.source.value === "discourse/lib/tracked-tools") {
|
|
144
|
+
denyImporting(
|
|
145
|
+
"trackedArray",
|
|
146
|
+
() =>
|
|
147
|
+
"'trackedArray' is deprecated. Use 'autoTrackedArray' from 'discourse/lib/tracked-tools' instead.",
|
|
148
|
+
(fixer, specifier) => {
|
|
149
|
+
const fixes = [
|
|
150
|
+
fixer.replaceText(specifier.imported, "autoTrackedArray"),
|
|
151
|
+
];
|
|
152
|
+
|
|
153
|
+
if (specifier.local.name === specifier.imported.name) {
|
|
154
|
+
const moduleScope = context.sourceCode.scopeManager.scopes.find(
|
|
155
|
+
(s) => s.type === "module"
|
|
156
|
+
);
|
|
157
|
+
const variable = moduleScope?.variables.find(
|
|
158
|
+
(v) => v.name === specifier.local.name
|
|
159
|
+
);
|
|
160
|
+
|
|
161
|
+
if (variable) {
|
|
162
|
+
for (const ref of variable.references) {
|
|
163
|
+
if (ref.identifier !== specifier.local) {
|
|
164
|
+
fixes.push(
|
|
165
|
+
fixer.replaceText(ref.identifier, "autoTrackedArray")
|
|
166
|
+
);
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
return fixes;
|
|
173
|
+
}
|
|
174
|
+
);
|
|
110
175
|
}
|
|
111
176
|
},
|
|
112
177
|
};
|
|
@@ -0,0 +1,395 @@
|
|
|
1
|
+
import { collectImports } from "./utils/analyze-imports.mjs";
|
|
2
|
+
import { fixImport } from "./utils/fix-import.mjs";
|
|
3
|
+
|
|
4
|
+
const SPECIFIER_MAPPING = {
|
|
5
|
+
TrackedArray: "trackedArray",
|
|
6
|
+
TrackedObject: "trackedObject",
|
|
7
|
+
TrackedMap: "trackedMap",
|
|
8
|
+
TrackedSet: "trackedSet",
|
|
9
|
+
TrackedWeakMap: "trackedWeakMap",
|
|
10
|
+
TrackedWeakSet: "trackedWeakSet",
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
const OLD_SOURCES = new Set([
|
|
14
|
+
"@ember-compat/tracked-built-ins",
|
|
15
|
+
"tracked-built-ins",
|
|
16
|
+
]);
|
|
17
|
+
const NEW_SOURCE = "@ember/reactive/collections";
|
|
18
|
+
|
|
19
|
+
function buildImportMessage(specifiersToTransform, oldSource) {
|
|
20
|
+
const oldNames = specifiersToTransform.map((s) => s.imported.name);
|
|
21
|
+
const newNames = oldNames.map((n) => SPECIFIER_MAPPING[n]);
|
|
22
|
+
|
|
23
|
+
const oldList = oldNames.map((n) => `'${n}'`).join(", ");
|
|
24
|
+
const newList = newNames.map((n) => `'${n}'`).join(", ");
|
|
25
|
+
|
|
26
|
+
const usageNotes = specifiersToTransform
|
|
27
|
+
.map((s) => {
|
|
28
|
+
const newName = SPECIFIER_MAPPING[s.imported.name];
|
|
29
|
+
const localName = s.local.name;
|
|
30
|
+
const callName = localName === s.imported.name ? newName : localName;
|
|
31
|
+
return `${callName}() instead of new ${localName}()`;
|
|
32
|
+
})
|
|
33
|
+
.join(", ");
|
|
34
|
+
|
|
35
|
+
return (
|
|
36
|
+
`Use ${newList} from '${NEW_SOURCE}' instead of ${oldList} from '${oldSource}'.` +
|
|
37
|
+
` Note: use ${usageNotes}.`
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
function buildNonNewMessage(specifier) {
|
|
42
|
+
const oldName = specifier.imported.name;
|
|
43
|
+
const newName = SPECIFIER_MAPPING[oldName];
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
`'${oldName}' must be migrated to '${NEW_SOURCE}', but this usage requires manual review.` +
|
|
47
|
+
` The new module exports '${newName}' as a factory function, not a class,` +
|
|
48
|
+
` so 'instanceof', class references, etc. will not work the same way.`
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
function buildNamingConflictMessage(specifier) {
|
|
53
|
+
const oldName = specifier.imported.name;
|
|
54
|
+
const newName = SPECIFIER_MAPPING[oldName];
|
|
55
|
+
|
|
56
|
+
return (
|
|
57
|
+
`Use \`${newName}\` from '${NEW_SOURCE}' instead of \`${oldName}\`:` +
|
|
58
|
+
` \`${newName}\` conflicts with an existing binding. Rename the conflicting identifier first.`
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function isNewExpression(ref) {
|
|
63
|
+
const parent = ref.identifier.parent;
|
|
64
|
+
return parent.type === "NewExpression" && parent.callee === ref.identifier;
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
function buildNewSpecifier(specifier) {
|
|
68
|
+
const oldName = specifier.imported.name;
|
|
69
|
+
const localName = specifier.local.name;
|
|
70
|
+
const newName = SPECIFIER_MAPPING[oldName] || oldName;
|
|
71
|
+
|
|
72
|
+
if (localName === oldName) {
|
|
73
|
+
return newName;
|
|
74
|
+
}
|
|
75
|
+
return `${newName} as ${localName}`;
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function buildOldSpecifier(specifier) {
|
|
79
|
+
const oldName = specifier.imported.name;
|
|
80
|
+
const localName = specifier.local.name;
|
|
81
|
+
|
|
82
|
+
if (localName === oldName) {
|
|
83
|
+
return oldName;
|
|
84
|
+
}
|
|
85
|
+
return `${oldName} as ${localName}`;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/**
|
|
89
|
+
* Checks whether the new function name for a specifier would conflict with
|
|
90
|
+
* an existing binding in the module scope (excluding the old import itself).
|
|
91
|
+
*
|
|
92
|
+
* @param {import('estree').ImportSpecifier} specifier
|
|
93
|
+
* @param {import('eslint').Scope.Scope} moduleScope
|
|
94
|
+
* @returns {boolean}
|
|
95
|
+
*/
|
|
96
|
+
function hasNamingConflict(specifier, moduleScope) {
|
|
97
|
+
// Aliased imports won't introduce a new name — the alias stays the same
|
|
98
|
+
if (specifier.local.name !== specifier.imported.name) {
|
|
99
|
+
return false;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
const newName = SPECIFIER_MAPPING[specifier.imported.name];
|
|
103
|
+
const variable = moduleScope?.variables.find((v) => v.name === newName);
|
|
104
|
+
|
|
105
|
+
// No variable with that name exists — no conflict
|
|
106
|
+
if (!variable) {
|
|
107
|
+
return false;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
// If the variable's only definition is an import from one of the old sources,
|
|
111
|
+
// that's the import we're replacing — not a real conflict
|
|
112
|
+
const isFromOldImport = variable.defs.every(
|
|
113
|
+
(def) =>
|
|
114
|
+
def.type === "ImportBinding" && OLD_SOURCES.has(def.parent?.source?.value)
|
|
115
|
+
);
|
|
116
|
+
|
|
117
|
+
return !isFromOldImport;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
/**
|
|
121
|
+
* Looks up the local name already used for a given new specifier name in the
|
|
122
|
+
* existing import from NEW_SOURCE. Returns the alias if found, or null.
|
|
123
|
+
*
|
|
124
|
+
* @param {string} newName - The new imported name (e.g. "trackedArray")
|
|
125
|
+
* @param {object|undefined} existingImportInfo - From collectImports
|
|
126
|
+
* @returns {string|null} The local alias, or null if not already imported
|
|
127
|
+
*/
|
|
128
|
+
function getExistingLocalName(newName, existingImportInfo) {
|
|
129
|
+
if (!existingImportInfo) {
|
|
130
|
+
return null;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
const spec = existingImportInfo.specifiers.find(
|
|
134
|
+
(s) => s.type === "ImportSpecifier" && s.imported.name === newName
|
|
135
|
+
);
|
|
136
|
+
|
|
137
|
+
return spec ? spec.local.name : null;
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
export default {
|
|
141
|
+
meta: {
|
|
142
|
+
type: "suggestion",
|
|
143
|
+
docs: {
|
|
144
|
+
description:
|
|
145
|
+
"Replace imports from '@ember-compat/tracked-built-ins' and 'tracked-built-ins'" +
|
|
146
|
+
` with '${NEW_SOURCE}'`,
|
|
147
|
+
},
|
|
148
|
+
fixable: "code",
|
|
149
|
+
schema: [],
|
|
150
|
+
},
|
|
151
|
+
|
|
152
|
+
create(context) {
|
|
153
|
+
return {
|
|
154
|
+
ImportDeclaration(node) {
|
|
155
|
+
const oldSource = node.source.value;
|
|
156
|
+
|
|
157
|
+
if (!OLD_SOURCES.has(oldSource)) {
|
|
158
|
+
return;
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
const specifiersToTransform = node.specifiers.filter(
|
|
162
|
+
(s) =>
|
|
163
|
+
s.type === "ImportSpecifier" && SPECIFIER_MAPPING[s.imported.name]
|
|
164
|
+
);
|
|
165
|
+
|
|
166
|
+
// Report on `tracked` import separately — it's likely confused with
|
|
167
|
+
// @glimmer/tracking's `tracked` decorator. There's no auto-fix since
|
|
168
|
+
// the replacement depends on usage context.
|
|
169
|
+
const trackedSpecifier = node.specifiers.find(
|
|
170
|
+
(s) => s.type === "ImportSpecifier" && s.imported.name === "tracked"
|
|
171
|
+
);
|
|
172
|
+
|
|
173
|
+
if (trackedSpecifier) {
|
|
174
|
+
context.report({
|
|
175
|
+
node: trackedSpecifier,
|
|
176
|
+
message:
|
|
177
|
+
`'tracked' should not be imported from '${oldSource}'.` +
|
|
178
|
+
` Use '@glimmer/tracking' for the @tracked decorator,` +
|
|
179
|
+
` or use the specific factory functions from '${NEW_SOURCE}'` +
|
|
180
|
+
` (e.g. trackedArray(), trackedMap(), trackedObject()).`,
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
if (specifiersToTransform.length === 0) {
|
|
185
|
+
return;
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
const moduleScope = context.sourceCode.scopeManager.scopes.find(
|
|
189
|
+
(s) => s.type === "module"
|
|
190
|
+
);
|
|
191
|
+
|
|
192
|
+
const imports = collectImports(context.sourceCode);
|
|
193
|
+
const existingNewSourceImport = imports.get(NEW_SOURCE);
|
|
194
|
+
|
|
195
|
+
// Classify each specifier as fixable or unfixable
|
|
196
|
+
const fixable = [];
|
|
197
|
+
const unfixable = [];
|
|
198
|
+
const nonNewRefs = [];
|
|
199
|
+
const namingConflicts = [];
|
|
200
|
+
|
|
201
|
+
for (const specifier of specifiersToTransform) {
|
|
202
|
+
const variable = moduleScope?.variables.find(
|
|
203
|
+
(v) => v.name === specifier.local.name
|
|
204
|
+
);
|
|
205
|
+
|
|
206
|
+
let specifierIsFixable = true;
|
|
207
|
+
|
|
208
|
+
if (variable) {
|
|
209
|
+
for (const ref of variable.references) {
|
|
210
|
+
if (!isNewExpression(ref)) {
|
|
211
|
+
specifierIsFixable = false;
|
|
212
|
+
nonNewRefs.push({ ref, specifier });
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
// Check naming conflicts only for specifiers that would otherwise
|
|
218
|
+
// be fixable and that don't already exist in the new source import
|
|
219
|
+
if (specifierIsFixable) {
|
|
220
|
+
const newName = SPECIFIER_MAPPING[specifier.imported.name];
|
|
221
|
+
const alreadyImported = getExistingLocalName(
|
|
222
|
+
newName,
|
|
223
|
+
existingNewSourceImport
|
|
224
|
+
);
|
|
225
|
+
|
|
226
|
+
if (!alreadyImported && hasNamingConflict(specifier, moduleScope)) {
|
|
227
|
+
specifierIsFixable = false;
|
|
228
|
+
namingConflicts.push(specifier);
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
232
|
+
if (specifierIsFixable) {
|
|
233
|
+
fixable.push(specifier);
|
|
234
|
+
} else {
|
|
235
|
+
unfixable.push(specifier);
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
|
|
239
|
+
const hasFix = fixable.length > 0;
|
|
240
|
+
|
|
241
|
+
// Specifiers not in SPECIFIER_MAPPING (e.g. `tracked`) that must stay
|
|
242
|
+
// on the old import if we split
|
|
243
|
+
const unmappedSpecifiers = node.specifiers.filter(
|
|
244
|
+
(s) =>
|
|
245
|
+
s.type === "ImportSpecifier" && !SPECIFIER_MAPPING[s.imported.name]
|
|
246
|
+
);
|
|
247
|
+
|
|
248
|
+
// Report on import node
|
|
249
|
+
context.report({
|
|
250
|
+
node,
|
|
251
|
+
message: buildImportMessage(specifiersToTransform, oldSource),
|
|
252
|
+
fix: hasFix
|
|
253
|
+
? (fixer) => {
|
|
254
|
+
const fixes = [];
|
|
255
|
+
const keepOnOld = [
|
|
256
|
+
...unfixable.map(buildOldSpecifier),
|
|
257
|
+
...unmappedSpecifiers.map(buildOldSpecifier),
|
|
258
|
+
];
|
|
259
|
+
|
|
260
|
+
if (existingNewSourceImport) {
|
|
261
|
+
// Merge into existing import from NEW_SOURCE
|
|
262
|
+
const specifiersToAdd = [];
|
|
263
|
+
|
|
264
|
+
for (const specifier of fixable) {
|
|
265
|
+
const newName = SPECIFIER_MAPPING[specifier.imported.name];
|
|
266
|
+
const alreadyImported = getExistingLocalName(
|
|
267
|
+
newName,
|
|
268
|
+
existingNewSourceImport
|
|
269
|
+
);
|
|
270
|
+
|
|
271
|
+
if (!alreadyImported) {
|
|
272
|
+
specifiersToAdd.push(buildNewSpecifier(specifier));
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
|
|
276
|
+
// Add new specifiers to the existing import
|
|
277
|
+
if (specifiersToAdd.length > 0) {
|
|
278
|
+
fixes.push(
|
|
279
|
+
fixImport(fixer, existingNewSourceImport.node, {
|
|
280
|
+
namedImportsToAdd: specifiersToAdd,
|
|
281
|
+
})
|
|
282
|
+
);
|
|
283
|
+
}
|
|
284
|
+
|
|
285
|
+
// Remove or trim the old import
|
|
286
|
+
if (keepOnOld.length === 0) {
|
|
287
|
+
fixes.push(fixer.remove(node));
|
|
288
|
+
} else {
|
|
289
|
+
fixes.push(
|
|
290
|
+
fixer.replaceText(
|
|
291
|
+
node,
|
|
292
|
+
`import { ${keepOnOld.join(", ")} } from "${oldSource}";`
|
|
293
|
+
)
|
|
294
|
+
);
|
|
295
|
+
}
|
|
296
|
+
} else if (keepOnOld.length === 0) {
|
|
297
|
+
// All fixable: replace entire import with new source
|
|
298
|
+
const newSpecifiers =
|
|
299
|
+
specifiersToTransform.map(buildNewSpecifier);
|
|
300
|
+
|
|
301
|
+
fixes.push(
|
|
302
|
+
fixer.replaceText(
|
|
303
|
+
node,
|
|
304
|
+
`import { ${newSpecifiers.join(", ")} } from "${NEW_SOURCE}";`
|
|
305
|
+
)
|
|
306
|
+
);
|
|
307
|
+
} else {
|
|
308
|
+
// Partial fix: split into two imports
|
|
309
|
+
const newSpecifiers = fixable.map(buildNewSpecifier);
|
|
310
|
+
|
|
311
|
+
fixes.push(
|
|
312
|
+
fixer.replaceText(
|
|
313
|
+
node,
|
|
314
|
+
`import { ${keepOnOld.join(", ")} } from "${oldSource}";\n` +
|
|
315
|
+
`import { ${newSpecifiers.join(", ")} } from "${NEW_SOURCE}";`
|
|
316
|
+
)
|
|
317
|
+
);
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
// Fix usage sites for fixable specifiers
|
|
321
|
+
for (const specifier of fixable) {
|
|
322
|
+
const localName = specifier.local.name;
|
|
323
|
+
const isAliased = localName !== specifier.imported.name;
|
|
324
|
+
const newFunctionName =
|
|
325
|
+
SPECIFIER_MAPPING[specifier.imported.name];
|
|
326
|
+
|
|
327
|
+
// Determine the name to use at call sites — if the new
|
|
328
|
+
// source already imports this with an alias, use that alias
|
|
329
|
+
const existingLocal = getExistingLocalName(
|
|
330
|
+
newFunctionName,
|
|
331
|
+
existingNewSourceImport
|
|
332
|
+
);
|
|
333
|
+
const callSiteName = existingLocal || newFunctionName;
|
|
334
|
+
|
|
335
|
+
const variable = moduleScope?.variables.find(
|
|
336
|
+
(v) => v.name === localName
|
|
337
|
+
);
|
|
338
|
+
if (!variable) {
|
|
339
|
+
continue;
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
for (const ref of variable.references) {
|
|
343
|
+
const parent = ref.identifier.parent;
|
|
344
|
+
|
|
345
|
+
if (
|
|
346
|
+
parent.type === "NewExpression" &&
|
|
347
|
+
parent.callee === ref.identifier
|
|
348
|
+
) {
|
|
349
|
+
// Remove `new ` keyword
|
|
350
|
+
fixes.push(
|
|
351
|
+
fixer.removeRange([
|
|
352
|
+
parent.range[0],
|
|
353
|
+
parent.callee.range[0],
|
|
354
|
+
])
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
// Rename identifier to the correct call-site name
|
|
358
|
+
if (existingLocal) {
|
|
359
|
+
// Use the alias from the existing import
|
|
360
|
+
fixes.push(
|
|
361
|
+
fixer.replaceText(ref.identifier, existingLocal)
|
|
362
|
+
);
|
|
363
|
+
} else if (!isAliased) {
|
|
364
|
+
fixes.push(
|
|
365
|
+
fixer.replaceText(ref.identifier, callSiteName)
|
|
366
|
+
);
|
|
367
|
+
}
|
|
368
|
+
}
|
|
369
|
+
}
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
return fixes;
|
|
373
|
+
}
|
|
374
|
+
: null,
|
|
375
|
+
});
|
|
376
|
+
|
|
377
|
+
// Report on each non-new reference
|
|
378
|
+
for (const { ref, specifier } of nonNewRefs) {
|
|
379
|
+
context.report({
|
|
380
|
+
node: ref.identifier,
|
|
381
|
+
message: buildNonNewMessage(specifier),
|
|
382
|
+
});
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
// Report on each naming conflict
|
|
386
|
+
for (const specifier of namingConflicts) {
|
|
387
|
+
context.report({
|
|
388
|
+
node: specifier,
|
|
389
|
+
message: buildNamingConflictMessage(specifier),
|
|
390
|
+
});
|
|
391
|
+
}
|
|
392
|
+
},
|
|
393
|
+
};
|
|
394
|
+
},
|
|
395
|
+
};
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
// key: the mutable argument name
|
|
2
|
+
// value array: component names
|
|
3
|
+
const MUTATING_COMPONENTS = {
|
|
4
|
+
"@value": [
|
|
5
|
+
"Input",
|
|
6
|
+
"Textarea",
|
|
7
|
+
"TextField",
|
|
8
|
+
"DatePicker",
|
|
9
|
+
"ChatChannelChooser",
|
|
10
|
+
],
|
|
11
|
+
"@checked": ["Input", "PreferenceCheckbox"],
|
|
12
|
+
"@selection": ["RadioButton", "InstallThemeItem", "ChatToTopicSelector"],
|
|
13
|
+
"@postAction": ["AdminPenaltyPostAction"],
|
|
14
|
+
"@reason": ["AdminPenaltyReason"],
|
|
15
|
+
"@tags": ["TagChooser", "ChatToTopicSelector"],
|
|
16
|
+
"@capsLockOn": ["PasswordField"],
|
|
17
|
+
"@message": ["FlagActionType"],
|
|
18
|
+
"@isConfirmed": ["FlagActionType"],
|
|
19
|
+
"@topicTitle": ["ChatToTopicSelector"],
|
|
20
|
+
"@categoryId": ["ChatToTopicSelector"],
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
function getImportIdentifier(node, source, namedImportIdentifier = null) {
|
|
24
|
+
if (node.source.value !== source) {
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
return node.specifiers
|
|
29
|
+
.filter((specifier) => {
|
|
30
|
+
return (
|
|
31
|
+
(specifier.type === "ImportSpecifier" &&
|
|
32
|
+
specifier.imported.name === namedImportIdentifier) ||
|
|
33
|
+
(!namedImportIdentifier && specifier.type === "ImportDefaultSpecifier")
|
|
34
|
+
);
|
|
35
|
+
})
|
|
36
|
+
.map((specifier) => specifier.local.name)
|
|
37
|
+
.pop();
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function isComponentClass(node, componentNames) {
|
|
41
|
+
return (
|
|
42
|
+
node.superClass?.type === "Identifier" &&
|
|
43
|
+
componentNames.has(node.superClass.name)
|
|
44
|
+
);
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getAssignedPropertyName(node) {
|
|
48
|
+
if (
|
|
49
|
+
node?.type !== "MemberExpression" ||
|
|
50
|
+
node.object?.type !== "ThisExpression"
|
|
51
|
+
) {
|
|
52
|
+
return;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
if (!node.computed && node.property?.type === "Identifier") {
|
|
56
|
+
return node.property.name;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
if (node.computed && node.property?.type === "Literal") {
|
|
60
|
+
return node.property.value;
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function hasTrackedDecorator(node) {
|
|
65
|
+
return node.decorators.some((decorator) => {
|
|
66
|
+
return (
|
|
67
|
+
decorator.expression.type === "Identifier" &&
|
|
68
|
+
decorator.expression.name === "tracked"
|
|
69
|
+
);
|
|
70
|
+
});
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
export default {
|
|
74
|
+
meta: {
|
|
75
|
+
type: "suggestion",
|
|
76
|
+
docs: {
|
|
77
|
+
description:
|
|
78
|
+
"Component properties should be @tracked only when they're reassigned at some point.",
|
|
79
|
+
},
|
|
80
|
+
schema: [], // no options
|
|
81
|
+
},
|
|
82
|
+
|
|
83
|
+
create(context) {
|
|
84
|
+
const componentNames = new Set();
|
|
85
|
+
const selectKitComponents = new Set();
|
|
86
|
+
let currentComponent;
|
|
87
|
+
|
|
88
|
+
function markAssigned(name) {
|
|
89
|
+
if (currentComponent && name) {
|
|
90
|
+
currentComponent.assigned.add(name);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function handleTrackedProperty(node) {
|
|
95
|
+
if (!currentComponent || node.static || !node.decorators?.length) {
|
|
96
|
+
return;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (!hasTrackedDecorator(node) || node.key?.type !== "Identifier") {
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
currentComponent.trackedProps.set(node.key.name, node);
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function handleGlimmerSubExpression(node) {
|
|
107
|
+
if (!currentComponent || !node.path?.head) {
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
if (node.path.head.type !== "VarHead" || node.path.head.name !== "mut") {
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
const firstParam = node.params?.[0];
|
|
116
|
+
if (firstParam.type !== "GlimmerPathExpression") {
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (firstParam.head.type === "ThisHead" && firstParam.tail.length) {
|
|
121
|
+
currentComponent.mutUses.add(firstParam.tail[0]);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
function handleGlimmerElementNode(node) {
|
|
126
|
+
if (!currentComponent) {
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
const componentName = node.tag || node.name;
|
|
131
|
+
if (!componentName) {
|
|
132
|
+
return;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
const attributes = node.attributes || [];
|
|
136
|
+
for (const attr of attributes) {
|
|
137
|
+
if (attr.type !== "GlimmerAttrNode") {
|
|
138
|
+
continue;
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
const isMutatingAttr =
|
|
142
|
+
MUTATING_COMPONENTS[attr.name]?.includes(componentName) ||
|
|
143
|
+
(attr.name === "@value" && selectKitComponents.has(componentName));
|
|
144
|
+
if (!isMutatingAttr) {
|
|
145
|
+
continue;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
if (!attr.value || attr.value.type !== "GlimmerMustacheStatement") {
|
|
149
|
+
continue;
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
const path = attr.value.path;
|
|
153
|
+
if (
|
|
154
|
+
path?.type === "GlimmerPathExpression" &&
|
|
155
|
+
path.head?.type === "ThisHead" &&
|
|
156
|
+
path.tail?.length
|
|
157
|
+
) {
|
|
158
|
+
currentComponent.valueUses.add(path.tail[0]);
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
}
|
|
162
|
+
|
|
163
|
+
function handleClass(node) {
|
|
164
|
+
if (isComponentClass(node, componentNames)) {
|
|
165
|
+
currentComponent = {
|
|
166
|
+
node,
|
|
167
|
+
trackedProps: new Map(),
|
|
168
|
+
assigned: new Set(),
|
|
169
|
+
mutUses: new Set(),
|
|
170
|
+
valueUses: new Set(),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
function handleClassExit(node) {
|
|
176
|
+
if (currentComponent?.node !== node) {
|
|
177
|
+
return;
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
for (const [name, propNode] of currentComponent.trackedProps) {
|
|
181
|
+
const reassigned = currentComponent.assigned.has(name);
|
|
182
|
+
const hasMutUse = currentComponent.mutUses.has(name);
|
|
183
|
+
const hasValueUse = currentComponent.valueUses.has(name);
|
|
184
|
+
|
|
185
|
+
if (!reassigned && !hasMutUse && !hasValueUse) {
|
|
186
|
+
context.report({
|
|
187
|
+
node: propNode,
|
|
188
|
+
message: `\`${name}\` property is @tracked but isn't modified anywhere.`,
|
|
189
|
+
});
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
currentComponent = null;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
ImportDeclaration(node) {
|
|
198
|
+
if (node.source.value.includes("/select-kit/")) {
|
|
199
|
+
node.specifiers.forEach((specifier) => {
|
|
200
|
+
selectKitComponents.add(specifier.local.name);
|
|
201
|
+
});
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
const glimmerComponentName = getImportIdentifier(
|
|
205
|
+
node,
|
|
206
|
+
"@glimmer/component"
|
|
207
|
+
);
|
|
208
|
+
if (glimmerComponentName) {
|
|
209
|
+
componentNames.add(glimmerComponentName);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
const emberComponentName = getImportIdentifier(
|
|
213
|
+
node,
|
|
214
|
+
"@ember/component"
|
|
215
|
+
);
|
|
216
|
+
if (emberComponentName) {
|
|
217
|
+
componentNames.add(emberComponentName);
|
|
218
|
+
}
|
|
219
|
+
},
|
|
220
|
+
|
|
221
|
+
ClassDeclaration: handleClass,
|
|
222
|
+
ClassExpression: handleClass,
|
|
223
|
+
|
|
224
|
+
"ClassDeclaration:exit": handleClassExit,
|
|
225
|
+
"ClassExpression:exit": handleClassExit,
|
|
226
|
+
|
|
227
|
+
ClassProperty: handleTrackedProperty,
|
|
228
|
+
PropertyDefinition: handleTrackedProperty,
|
|
229
|
+
|
|
230
|
+
AssignmentExpression(node) {
|
|
231
|
+
markAssigned(getAssignedPropertyName(node.left));
|
|
232
|
+
},
|
|
233
|
+
|
|
234
|
+
UpdateExpression(node) {
|
|
235
|
+
markAssigned(getAssignedPropertyName(node.argument));
|
|
236
|
+
},
|
|
237
|
+
|
|
238
|
+
GlimmerSubExpression: handleGlimmerSubExpression,
|
|
239
|
+
GlimmerElementNode: handleGlimmerElementNode,
|
|
240
|
+
};
|
|
241
|
+
},
|
|
242
|
+
};
|
package/eslint.mjs
CHANGED
|
@@ -21,12 +21,14 @@ import keepArraySorted from "./eslint-rules/keep-array-sorted.mjs";
|
|
|
21
21
|
import lineAfterImports from "./eslint-rules/line-after-imports.mjs";
|
|
22
22
|
import lineBeforeDefaultExport from "./eslint-rules/line-before-default-export.mjs";
|
|
23
23
|
import linesBetweenClassMembers from "./eslint-rules/lines-between-class-members.mjs";
|
|
24
|
+
import migrateTrackedBuiltInsToEmberCollections from "./eslint-rules/migrate-tracked-built-ins-to-ember-collections.mjs";
|
|
24
25
|
import movedPackagesImportPaths from "./eslint-rules/moved-packages-import-paths.mjs";
|
|
25
26
|
import noCurlyComponents from "./eslint-rules/no-curly-components.mjs";
|
|
26
27
|
import noDiscourseComputed from "./eslint-rules/no-discourse-computed.mjs";
|
|
27
28
|
import noOnclick from "./eslint-rules/no-onclick.mjs";
|
|
28
29
|
import noRouteTemplate from "./eslint-rules/no-route-template.mjs";
|
|
29
30
|
import noSimpleQuerySelector from "./eslint-rules/no-simple-query-selector.mjs";
|
|
31
|
+
import noUnnecessaryTracked from "./eslint-rules/no-unnecessary-tracked.mjs";
|
|
30
32
|
import noUnusedServices from "./eslint-rules/no-unused-services.mjs";
|
|
31
33
|
import pluginApiNoVersion from "./eslint-rules/plugin-api-no-version.mjs";
|
|
32
34
|
import serviceInjectImport from "./eslint-rules/service-inject-import.mjs";
|
|
@@ -146,6 +148,9 @@ export default [
|
|
|
146
148
|
"moved-packages-import-paths": movedPackagesImportPaths,
|
|
147
149
|
"no-discourse-computed": noDiscourseComputed,
|
|
148
150
|
"test-filename-suffix": testFilenameSuffix,
|
|
151
|
+
"no-unnecessary-tracked": noUnnecessaryTracked,
|
|
152
|
+
"migrate-tracked-built-ins-to-ember-collections":
|
|
153
|
+
migrateTrackedBuiltInsToEmberCollections,
|
|
149
154
|
},
|
|
150
155
|
},
|
|
151
156
|
},
|
|
@@ -317,6 +322,8 @@ export default [
|
|
|
317
322
|
"discourse/test-filename-suffix": ["error"],
|
|
318
323
|
"discourse/keep-array-sorted": ["error"],
|
|
319
324
|
"discourse/no-discourse-computed": ["error"],
|
|
325
|
+
"discourse/no-unnecessary-tracked": ["warn"],
|
|
326
|
+
"discourse/migrate-tracked-built-ins-to-ember-collections": ["error"],
|
|
320
327
|
},
|
|
321
328
|
},
|
|
322
329
|
{
|