@discourse/lint-configs 2.44.0 → 2.45.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,612 @@
1
+ /**
2
+ * @fileoverview Fixer logic for the `no-computed-macros` ESLint rule.
3
+ *
4
+ * Generates a combined ESLint fixer function per class that:
5
+ * 1. Removes each macro PropertyDefinition
6
+ * 2. Collects @tracked declarations and inserts them at the correct
7
+ * sort-class-members section:
8
+ * - Regular tracked → [tracked-properties]
9
+ * - Override fields (e.g. oneWay `_propOverride`) → [private-properties]
10
+ * 3. Inserts all generated getters at the correct class body position
11
+ * (the [everything-else] section, after all property-like members)
12
+ *
13
+ * This "remove + insert elsewhere" approach produces correct
14
+ * sort-class-members order in a single --fix pass, avoiding the messy
15
+ * intermediate state of in-place replacement.
16
+ */
17
+
18
+ // Names of lifecycle methods that have their own sort-class-members slot
19
+ // (they come before [everything-else] in the sort order).
20
+ const LIFECYCLE_METHODS = new Set(["constructor", "init", "willDestroy"]);
21
+
22
+ // Decorators that make a property reactive (equivalent to @tracked).
23
+ // Members with these decorators do not need @tracked added.
24
+ const TRACKED_DECORATORS = new Set([
25
+ "tracked",
26
+ "trackedArray",
27
+ "dedupeTracked",
28
+ "resettableTracked",
29
+ ]);
30
+
31
+ // ---------------------------------------------------------------------------
32
+ // Public API
33
+ // ---------------------------------------------------------------------------
34
+
35
+ /**
36
+ * Create a combined fixer function for ALL fixable macro usages in one class.
37
+ *
38
+ * The returned function produces an array of Fix objects that:
39
+ * - Remove each macro PropertyDefinition
40
+ * - Collect and insert @tracked declarations at the tracked section
41
+ * - Insert all generated getters at the correct position
42
+ *
43
+ * @param {import('./computed-macros-analysis.mjs').MacroUsage[]} classUsages
44
+ * @param {Set<import('estree').Node>} existingNodesToDecorate
45
+ * @param {import('eslint').SourceCode} sourceCode
46
+ * @returns {(fixer: import('eslint').Rule.RuleFixer) => import('eslint').Rule.Fix[]}
47
+ */
48
+ export function createClassFix(
49
+ classUsages,
50
+ existingNodesToDecorate,
51
+ sourceCode
52
+ ) {
53
+ return function (fixer) {
54
+ const fixes = [];
55
+ const text = sourceCode.getText();
56
+ const classBody = classUsages[0].propertyNode.parent;
57
+ const indent = detectIndent(classUsages[0].propertyNode, sourceCode);
58
+ const macroNodeSet = new Set(classUsages.map((u) => u.propertyNode));
59
+
60
+ // ---- 1. Remove all macro PropertyDefinitions ----
61
+ for (const usage of classUsages) {
62
+ let start = getNodeStart(usage, sourceCode);
63
+ let end = getNodeEnd(usage.propertyNode, sourceCode);
64
+ // Consume trailing newline for clean line removal
65
+ if (end < text.length && text[end] === "\n") {
66
+ end++;
67
+ }
68
+ // Consume one preceding blank line to avoid orphaned double blank
69
+ // lines after the macro is removed
70
+ if (start >= 2 && text[start - 1] === "\n" && text[start - 2] === "\n") {
71
+ start--;
72
+ }
73
+ fixes.push(fixer.replaceTextRange([start, end], ""));
74
+ }
75
+
76
+ // ---- 2. Collect @tracked declarations ----
77
+ // Regular tracked (from trackedDeps / existingNodesToDecorate) go to
78
+ // [tracked-properties]; override fields (from overrideTrackedFields,
79
+ // e.g. oneWay's `_propOverride`) go to [private-properties] because
80
+ // their `_` prefix matches the sort-class-members private pattern.
81
+ const trackedLines = [];
82
+ const privateTrackedLines = [];
83
+ const seenTrackedDeps = new Set();
84
+ const processedNodes = new Set();
85
+
86
+ for (const usage of classUsages) {
87
+ // Override tracked fields → [private-properties] section
88
+ if (usage.transform.overrideTrackedFields) {
89
+ for (const field of usage.transform.overrideTrackedFields({
90
+ propName: usage.propName,
91
+ })) {
92
+ const line =
93
+ field.initializer != null
94
+ ? `${indent}@tracked ${field.name} = ${field.initializer};\n`
95
+ : `${indent}@tracked ${field.name};\n`;
96
+ privateTrackedLines.push(line);
97
+ }
98
+ }
99
+
100
+ // New @tracked declarations from trackedDeps → [tracked-properties]
101
+ if (usage.allLocal && usage.trackedDeps?.length > 0) {
102
+ for (const dep of usage.trackedDeps) {
103
+ if (!seenTrackedDeps.has(dep)) {
104
+ seenTrackedDeps.add(dep);
105
+ trackedLines.push(`${indent}@tracked ${dep};\n`);
106
+ }
107
+ }
108
+ }
109
+
110
+ // Move existing members with @tracked prepended → [tracked-properties]
111
+ if (usage.existingNodesToDecorate) {
112
+ for (const memberNode of usage.existingNodesToDecorate) {
113
+ if (processedNodes.has(memberNode)) {
114
+ continue;
115
+ }
116
+ processedNodes.add(memberNode);
117
+
118
+ // Capture source text (key through trailing semicolons)
119
+ let endPos = memberNode.range[1];
120
+ while (endPos < text.length && text[endPos] === ";") {
121
+ endPos++;
122
+ }
123
+ const memberSource = text.slice(memberNode.range[0], endPos);
124
+ trackedLines.push(`${indent}@tracked ${memberSource}\n`);
125
+
126
+ // Remove from original position (full line)
127
+ const removeStart = lineStartOf(memberNode, text);
128
+ let removeEnd = endPos;
129
+ if (removeEnd < text.length && text[removeEnd] === "\n") {
130
+ removeEnd++;
131
+ }
132
+ fixes.push(fixer.replaceTextRange([removeStart, removeEnd], ""));
133
+ }
134
+ }
135
+ }
136
+
137
+ // Compute insertion positions
138
+ const trackedInsertPos =
139
+ trackedLines.length > 0
140
+ ? findTrackedInsertionPoint(
141
+ classBody,
142
+ macroNodeSet,
143
+ existingNodesToDecorate,
144
+ text
145
+ )
146
+ : null;
147
+
148
+ const privateInsertPos =
149
+ privateTrackedLines.length > 0
150
+ ? findPrivatePropertyInsertionPoint(
151
+ classBody,
152
+ macroNodeSet,
153
+ existingNodesToDecorate,
154
+ text
155
+ )
156
+ : null;
157
+
158
+ // ESLint rejects overlapping fixes at the same position, so when both
159
+ // insertions target the same point (e.g. class with only macro members),
160
+ // combine them into a single insertion: tracked first, then private.
161
+ if (
162
+ trackedInsertPos !== null &&
163
+ privateInsertPos !== null &&
164
+ trackedInsertPos === privateInsertPos
165
+ ) {
166
+ fixes.push(
167
+ fixer.insertTextBeforeRange(
168
+ [trackedInsertPos, trackedInsertPos],
169
+ trackedLines.join("") + "\n" + privateTrackedLines.join("")
170
+ )
171
+ );
172
+ } else {
173
+ if (trackedInsertPos !== null) {
174
+ fixes.push(
175
+ fixer.insertTextBeforeRange(
176
+ [trackedInsertPos, trackedInsertPos],
177
+ trackedLines.join("")
178
+ )
179
+ );
180
+ }
181
+
182
+ // Insert override fields at [private-properties] section (after all
183
+ // other PropertyDefinitions, before methods/getters).
184
+ if (privateInsertPos !== null) {
185
+ // Blank line separator before the private section when there are
186
+ // other property-like members above
187
+ const hasPropertiesAbove = classBody.body.some(
188
+ (m) =>
189
+ !macroNodeSet.has(m) &&
190
+ !existingNodesToDecorate.has(m) &&
191
+ m.type === "PropertyDefinition" &&
192
+ m.range[1] <= privateInsertPos
193
+ );
194
+ const privatePrefix = hasPropertiesAbove ? "\n" : "";
195
+ fixes.push(
196
+ fixer.insertTextBeforeRange(
197
+ [privateInsertPos, privateInsertPos],
198
+ privatePrefix + privateTrackedLines.join("")
199
+ )
200
+ );
201
+ }
202
+ }
203
+
204
+ // ---- 3. Insert all getters at the correct position ----
205
+ const getterTexts = classUsages.map((u) =>
206
+ buildGetterCode(u, indent, sourceCode)
207
+ );
208
+ const allGettersText = getterTexts.join("\n");
209
+
210
+ const insertPos = findGetterInsertionPoint(
211
+ classBody,
212
+ macroNodeSet,
213
+ sourceCode
214
+ );
215
+ const hasContent = hasContentBeforeInsertion(
216
+ classUsages,
217
+ classBody,
218
+ macroNodeSet,
219
+ insertPos
220
+ );
221
+ // Blank line before getters when there's content above (field → method)
222
+ const prefix = hasContent ? "\n" : "";
223
+
224
+ fixes.push(
225
+ fixer.insertTextBeforeRange(
226
+ [insertPos, insertPos],
227
+ prefix + allGettersText + "\n"
228
+ )
229
+ );
230
+
231
+ return fixes;
232
+ };
233
+ }
234
+
235
+ // ---------------------------------------------------------------------------
236
+ // Getter code generation
237
+ // ---------------------------------------------------------------------------
238
+
239
+ /**
240
+ * Build the full getter code string including decorator.
241
+ *
242
+ * @param {import('./computed-macros-analysis.mjs').MacroUsage} usage
243
+ * @param {string} indent
244
+ * @param {import('eslint').SourceCode} sourceCode
245
+ * @returns {string}
246
+ */
247
+ function buildGetterCode(usage, indent, sourceCode) {
248
+ const {
249
+ transform,
250
+ propName,
251
+ allLocal,
252
+ dependentKeys,
253
+ literalArgs,
254
+ argNodes,
255
+ } = usage;
256
+ const transformArgs = { literalArgs, argNodes, propName, sourceCode };
257
+ const bodyRaw = transform.toGetterBody(transformArgs);
258
+
259
+ // Build decorator line
260
+ let decoratorLine;
261
+ if (allLocal) {
262
+ decoratorLine = `${indent}@dependentKeyCompat`;
263
+ } else {
264
+ const keys = dependentKeys.map((k) => JSON.stringify(k)).join(", ");
265
+ decoratorLine = `${indent}@computed(${keys})`;
266
+ }
267
+
268
+ // Build getter body — handle multi-line bodies (e.g. sort)
269
+ const bodyLines = bodyRaw.split("\n");
270
+ const bodyIndent = `${indent} `;
271
+ const formattedBody = bodyLines
272
+ .map((line) => `${bodyIndent}${line}`)
273
+ .join("\n");
274
+
275
+ const parts = [
276
+ decoratorLine,
277
+ `${indent}get ${propName}() {`,
278
+ formattedBody,
279
+ `${indent}}`,
280
+ ];
281
+
282
+ // Append setter for bidirectional macros (e.g. alias)
283
+ if (transform.toSetterBody) {
284
+ const setterBody = transform.toSetterBody({
285
+ ...transformArgs,
286
+ useEmberSet: !allLocal,
287
+ });
288
+ const setterLines = setterBody.split("\n");
289
+ const formattedSetterBody = setterLines
290
+ .map((line) => `${bodyIndent}${line}`)
291
+ .join("\n");
292
+ parts.push(
293
+ `${indent}set ${propName}(value) {`,
294
+ formattedSetterBody,
295
+ `${indent}}`
296
+ );
297
+ }
298
+
299
+ return parts.join("\n");
300
+ }
301
+
302
+ // ---------------------------------------------------------------------------
303
+ // Insertion point logic
304
+ // ---------------------------------------------------------------------------
305
+
306
+ /**
307
+ * Find the character position where generated getters should be inserted.
308
+ *
309
+ * Strategy (matches sort-class-members order):
310
+ * 1. If there's a non-static, non-macro instance MethodDefinition that is NOT
311
+ * a lifecycle method, insert before it (start of [everything-else] section).
312
+ * Static methods come before all instance members in the sort order, so
313
+ * they must be skipped.
314
+ * 2. Otherwise, if there's a last non-macro member, insert after it.
315
+ * 3. Fallback: insert before the closing `}` of the class body.
316
+ *
317
+ * @param {import('estree').ClassBody} classBody
318
+ * @param {Set<import('estree').Node>} macroNodeSet
319
+ * @param {import('eslint').SourceCode} sourceCode
320
+ * @returns {number} Character position at the start of a line
321
+ */
322
+ function findGetterInsertionPoint(classBody, macroNodeSet, sourceCode) {
323
+ const text = sourceCode.getText();
324
+ const members = classBody.body;
325
+
326
+ // Look for the first non-static, non-macro instance MethodDefinition
327
+ // that isn't a lifecycle method. Static methods come before all instance
328
+ // members in the sort order, so inserting among them would be wrong.
329
+ for (const member of members) {
330
+ if (macroNodeSet.has(member) || member.static) {
331
+ continue;
332
+ }
333
+ if (member.type === "MethodDefinition") {
334
+ const name = member.key?.name;
335
+ if (!LIFECYCLE_METHODS.has(name)) {
336
+ // Insert before this method's line
337
+ return lineStartOf(member, text);
338
+ }
339
+ }
340
+ }
341
+
342
+ // No existing instance method found. Insert after the last non-macro member
343
+ // (including static members — they precede [everything-else] in sort order).
344
+ let lastNonMacro = null;
345
+ for (const member of members) {
346
+ if (!macroNodeSet.has(member)) {
347
+ lastNonMacro = member;
348
+ }
349
+ }
350
+
351
+ if (lastNonMacro) {
352
+ // Position after this member's line (past semicolons and newline)
353
+ let pos = lastNonMacro.range[1];
354
+ while (pos < text.length && text[pos] === ";") {
355
+ pos++;
356
+ }
357
+ if (pos < text.length && text[pos] === "\n") {
358
+ pos++;
359
+ }
360
+ return pos;
361
+ }
362
+
363
+ // Fallback: before the closing `}` of the class body
364
+ return lineStartOf({ range: [classBody.range[1] - 1] }, text);
365
+ }
366
+
367
+ /**
368
+ * Find the position to insert @tracked declarations.
369
+ *
370
+ * Strategy: insert right after the last existing @tracked property line
371
+ * (skipping macro nodes and members being moved). Falls back to the start
372
+ * of the class body (after `{\n`) if no @tracked properties exist.
373
+ *
374
+ * @param {import('estree').ClassBody} classBody
375
+ * @param {Set<import('estree').Node>} macroNodeSet
376
+ * @param {Set<import('estree').Node>} existingNodesToDecorate
377
+ * @param {string} text
378
+ * @returns {number}
379
+ */
380
+ function findTrackedInsertionPoint(
381
+ classBody,
382
+ macroNodeSet,
383
+ existingNodesToDecorate,
384
+ text
385
+ ) {
386
+ let lastTrackedEnd = null;
387
+
388
+ for (const member of classBody.body) {
389
+ if (macroNodeSet.has(member) || existingNodesToDecorate.has(member)) {
390
+ continue;
391
+ }
392
+ if (member.type !== "PropertyDefinition") {
393
+ continue;
394
+ }
395
+
396
+ if (hasTrackedLikeDecorator(member)) {
397
+ let pos = member.range[1];
398
+ while (pos < text.length && text[pos] === ";") {
399
+ pos++;
400
+ }
401
+ if (pos < text.length && text[pos] === "\n") {
402
+ pos++;
403
+ }
404
+ lastTrackedEnd = pos;
405
+ }
406
+ }
407
+
408
+ if (lastTrackedEnd !== null) {
409
+ return lastTrackedEnd;
410
+ }
411
+
412
+ // No tracked properties found — insert at start of class body
413
+ let pos = classBody.range[0] + 1;
414
+ if (pos < text.length && text[pos] === "\n") {
415
+ pos++;
416
+ }
417
+ return pos;
418
+ }
419
+
420
+ /**
421
+ * Find the position to insert override tracked fields (e.g. oneWay's
422
+ * `_propOverride`). These use a `_` prefix, so sort-class-members classifies
423
+ * them as [private-properties], which comes after [properties] and before
424
+ * lifecycle methods / [everything-else].
425
+ *
426
+ * Strategy: insert after the last non-macro, non-moved PropertyDefinition.
427
+ * This places private tracked fields after all other property-like members.
428
+ *
429
+ * @param {import('estree').ClassBody} classBody
430
+ * @param {Set<import('estree').Node>} macroNodeSet
431
+ * @param {Set<import('estree').Node>} existingNodesToDecorate
432
+ * @param {string} text
433
+ * @returns {number}
434
+ */
435
+ function findPrivatePropertyInsertionPoint(
436
+ classBody,
437
+ macroNodeSet,
438
+ existingNodesToDecorate,
439
+ text
440
+ ) {
441
+ let lastPropertyEnd = null;
442
+
443
+ for (const member of classBody.body) {
444
+ if (macroNodeSet.has(member) || existingNodesToDecorate.has(member)) {
445
+ continue;
446
+ }
447
+ if (member.type !== "PropertyDefinition") {
448
+ continue;
449
+ }
450
+
451
+ let pos = member.range[1];
452
+ while (pos < text.length && text[pos] === ";") {
453
+ pos++;
454
+ }
455
+ if (pos < text.length && text[pos] === "\n") {
456
+ pos++;
457
+ }
458
+ lastPropertyEnd = pos;
459
+ }
460
+
461
+ if (lastPropertyEnd !== null) {
462
+ return lastPropertyEnd;
463
+ }
464
+
465
+ // No properties found — insert at start of class body
466
+ let pos = classBody.range[0] + 1;
467
+ if (pos < text.length && text[pos] === "\n") {
468
+ pos++;
469
+ }
470
+ return pos;
471
+ }
472
+
473
+ /**
474
+ * Check whether a class member has a tracked-like decorator.
475
+ *
476
+ * @param {import('estree').Node} member
477
+ * @returns {boolean}
478
+ */
479
+ function hasTrackedLikeDecorator(member) {
480
+ return (
481
+ member.decorators?.some((d) => {
482
+ const expr = d.expression;
483
+ return (
484
+ (expr.type === "Identifier" && TRACKED_DECORATORS.has(expr.name)) ||
485
+ (expr.type === "CallExpression" &&
486
+ expr.callee?.type === "Identifier" &&
487
+ TRACKED_DECORATORS.has(expr.callee.name))
488
+ );
489
+ }) ?? false
490
+ );
491
+ }
492
+
493
+ /**
494
+ * Get the start-of-line position for a node (walks back past whitespace).
495
+ *
496
+ * @param {{ range?: number[], decorators?: Array<{ range: number[] }> }} node
497
+ * @param {string} text
498
+ * @returns {number}
499
+ */
500
+ function lineStartOf(node, text) {
501
+ let pos =
502
+ node.decorators?.length > 0 ? node.decorators[0].range[0] : node.range[0];
503
+
504
+ while (pos > 0 && text[pos - 1] !== "\n") {
505
+ pos--;
506
+ }
507
+ return pos;
508
+ }
509
+
510
+ /**
511
+ * Check whether there will be visible content before the getter insertion
512
+ * point after fixes are applied. Used to decide whether to add a blank-line
513
+ * prefix (`\n`) before the getters.
514
+ *
515
+ * @param {import('./computed-macros-analysis.mjs').MacroUsage[]} classUsages
516
+ * @param {import('estree').ClassBody} classBody
517
+ * @param {Set<import('estree').Node>} macroNodeSet
518
+ * @param {number} insertPos Character position where getters will be inserted
519
+ * @returns {boolean}
520
+ */
521
+ function hasContentBeforeInsertion(
522
+ classUsages,
523
+ classBody,
524
+ macroNodeSet,
525
+ insertPos
526
+ ) {
527
+ // True if any macro produces @tracked declarations (placed at the
528
+ // tracked section, which is always before the getter insertion point)
529
+ const hasTrackedContent = classUsages.some(
530
+ (u) =>
531
+ (u.allLocal && u.trackedDeps?.length > 0) ||
532
+ u.transform.overrideTrackedFields
533
+ );
534
+ if (hasTrackedContent) {
535
+ return true;
536
+ }
537
+ // True if any existing members are being moved (they get re-inserted
538
+ // at the tracked section, before the getter insertion point)
539
+ const hasExistingToDecorate = classUsages.some(
540
+ (u) => u.existingNodesToDecorate?.length > 0
541
+ );
542
+ if (hasExistingToDecorate) {
543
+ return true;
544
+ }
545
+ // True if there are non-macro members before the insertion point
546
+ return classBody.body.some(
547
+ (m) => !macroNodeSet.has(m) && m.range[0] < insertPos
548
+ );
549
+ }
550
+
551
+ // ---------------------------------------------------------------------------
552
+ // Node range helpers
553
+ // ---------------------------------------------------------------------------
554
+
555
+ /**
556
+ * Get the start position of a usage, including its decorators AND any
557
+ * leading whitespace on the same line. This ensures the replacement range
558
+ * covers the full indentation so the generated code can control its own
559
+ * indentation without doubling.
560
+ */
561
+ function getNodeStart(usage, sourceCode) {
562
+ const { propertyNode } = usage;
563
+ const text = sourceCode.getText();
564
+ let pos =
565
+ propertyNode.decorators?.length > 0
566
+ ? propertyNode.decorators[0].range[0]
567
+ : propertyNode.range[0];
568
+
569
+ // Walk back to the start of the line (past whitespace)
570
+ while (pos > 0 && text[pos - 1] !== "\n") {
571
+ pos--;
572
+ }
573
+
574
+ return pos;
575
+ }
576
+
577
+ /**
578
+ * Get the end position of a PropertyDefinition, consuming trailing semicolons.
579
+ */
580
+ function getNodeEnd(node, sourceCode) {
581
+ let end = node.range[1];
582
+ const text = sourceCode.getText();
583
+ while (end < text.length && text[end] === ";") {
584
+ end++;
585
+ }
586
+ return end;
587
+ }
588
+
589
+ // ---------------------------------------------------------------------------
590
+ // Indentation helpers
591
+ // ---------------------------------------------------------------------------
592
+
593
+ /**
594
+ * Detect the indentation of a node by looking at leading whitespace.
595
+ */
596
+ function detectIndent(node, sourceCode) {
597
+ const text = sourceCode.getText();
598
+ let pos =
599
+ node.decorators?.length > 0 ? node.decorators[0].range[0] : node.range[0];
600
+
601
+ while (pos > 0 && text[pos - 1] !== "\n") {
602
+ pos--;
603
+ }
604
+
605
+ let indent = "";
606
+ while (pos < text.length && (text[pos] === " " || text[pos] === "\t")) {
607
+ indent += text[pos];
608
+ pos++;
609
+ }
610
+
611
+ return indent;
612
+ }