@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,903 @@
1
+ /**
2
+ * @fileoverview Analysis helpers for the `no-computed-macros` ESLint rule.
3
+ *
4
+ * Performs read-only AST traversal to detect usages of computed property macros
5
+ * from `@ember/object/computed`, `discourse/lib/computed`, and
6
+ * `discourse/lib/decorators`, determines whether
7
+ * each usage can be auto-fixed, and collects the information the fixer needs.
8
+ */
9
+
10
+ import {
11
+ isLocalKey,
12
+ MACRO_SOURCES,
13
+ MACRO_TRANSFORMS,
14
+ SOURCE_ALIASES,
15
+ } from "./macro-transforms.mjs";
16
+
17
+ // ---------------------------------------------------------------------------
18
+ // Types
19
+ // ---------------------------------------------------------------------------
20
+
21
+ /**
22
+ * @typedef {Object} MacroUsage
23
+ * @property {string} macroName - The original macro name (e.g. "alias", "not")
24
+ * @property {string} localName - The local identifier name (may differ due to aliases)
25
+ * @property {import('./macro-transforms.mjs').MacroTransform} transform
26
+ * @property {import('estree').Node} decoratorNode - The Decorator AST node
27
+ * @property {import('estree').Node} propertyNode - The PropertyDefinition AST node
28
+ * @property {string[]} literalArgs - Literal string/number argument values
29
+ * @property {import('estree').Node[]} argNodes - Raw AST argument nodes
30
+ * @property {string} propName - The decorated property name
31
+ * @property {string[]} dependentKeys - Resolved dependent keys
32
+ * @property {boolean} allLocal - Whether all dependent keys are local (no dots)
33
+ * @property {boolean} canAutoFix
34
+ * @property {string} [messageId] - Message ID when not auto-fixable
35
+ * @property {Object} [reportData] - Data for error message interpolation
36
+ * @property {string[]} [trackedDeps] - Local deps needing a NEW @tracked declaration (not existing members)
37
+ * @property {import('estree').Node[]} [existingNodesToDecorate] - Existing PropertyDefinition nodes needing @tracked added
38
+ */
39
+
40
+ /**
41
+ * @typedef {Object} MacroAnalysisResult
42
+ * @property {MacroUsage[]} usages - All detected macro usages
43
+ * @property {Map<string, string>} importedMacros - Map from local name → macro name
44
+ * @property {Map<string, import('estree').ImportDeclaration>} macroImportNodes - Import nodes by source
45
+ */
46
+
47
+ // ---------------------------------------------------------------------------
48
+ // Analysis
49
+ // ---------------------------------------------------------------------------
50
+
51
+ /**
52
+ * Analyze the source AST for computed property macro usage.
53
+ *
54
+ * @param {import('eslint').SourceCode} sourceCode
55
+ * @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} imports
56
+ * Result of `collectImports(sourceCode)`.
57
+ * @returns {MacroAnalysisResult}
58
+ */
59
+ export function analyzeMacroUsage(sourceCode, imports) {
60
+ const importedMacros = collectMacroImports(imports);
61
+ const macroImportNodes = collectMacroImportNodes(imports);
62
+ const usages = [];
63
+
64
+ if (importedMacros.size === 0) {
65
+ return { usages, importedMacros, macroImportNodes };
66
+ }
67
+
68
+ for (const statement of sourceCode.ast.body) {
69
+ walkNode(statement, (node) => {
70
+ // Decorator usage on a PropertyDefinition in a native class
71
+ if (node.type === "PropertyDefinition" && node.decorators) {
72
+ analyzePropertyDefinition(node, importedMacros, usages, sourceCode);
73
+ }
74
+ });
75
+ }
76
+
77
+ // Post-process tracked deps:
78
+ // 1. Remove deps that are themselves macro properties being converted to
79
+ // getters — adding @tracked to something that will become a getter is wrong.
80
+ // 2. Propagate @computed requirement transitively — if a macro depends on
81
+ // another macro that uses @computed, it must also use @computed.
82
+ // 3. Deduplicate so each new @tracked declaration is emitted by one fixer only.
83
+ excludeDepsBeingConverted(usages);
84
+ excludeImplicitInjectionDeps(usages);
85
+ forceComputedForClassicComponents(usages, imports);
86
+ excludeUndeclaredDepsInSubclasses(usages, imports);
87
+ propagateComputedRequirement(usages);
88
+ deduplicateTrackedDeps(usages);
89
+
90
+ return { usages, importedMacros, macroImportNodes };
91
+ }
92
+
93
+ // ---------------------------------------------------------------------------
94
+ // Import collection
95
+ // ---------------------------------------------------------------------------
96
+
97
+ /**
98
+ * Scan the imports map for macro names from all target sources.
99
+ * Returns a map from local identifier name → canonical macro name.
100
+ *
101
+ * @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} imports
102
+ * @returns {Map<string, string>}
103
+ */
104
+ function collectMacroImports(imports) {
105
+ const result = new Map();
106
+
107
+ for (const [, transform] of MACRO_TRANSFORMS) {
108
+ const importInfo = imports.get(transform.source);
109
+ if (!importInfo) {
110
+ continue;
111
+ }
112
+
113
+ for (const spec of importInfo.specifiers) {
114
+ if (spec.type !== "ImportSpecifier") {
115
+ continue;
116
+ }
117
+ const importedName = spec.imported.name;
118
+ if (MACRO_TRANSFORMS.has(importedName)) {
119
+ result.set(spec.local.name, importedName);
120
+ }
121
+ }
122
+ }
123
+
124
+ // Check alias sources (e.g. discourse/lib/decorators → @ember/object/computed)
125
+ for (const [aliasSource, canonicalSource] of SOURCE_ALIASES) {
126
+ const importInfo = imports.get(aliasSource);
127
+ if (!importInfo) {
128
+ continue;
129
+ }
130
+
131
+ for (const spec of importInfo.specifiers) {
132
+ if (spec.type !== "ImportSpecifier") {
133
+ continue;
134
+ }
135
+ const importedName = spec.imported.name;
136
+ const transform = MACRO_TRANSFORMS.get(importedName);
137
+ if (transform && transform.source === canonicalSource) {
138
+ result.set(spec.local.name, importedName);
139
+ }
140
+ }
141
+ }
142
+
143
+ return result;
144
+ }
145
+
146
+ /**
147
+ * Collect the ImportDeclaration nodes for each macro source that has macros.
148
+ *
149
+ * @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} imports
150
+ * @returns {Map<string, import('estree').ImportDeclaration>}
151
+ */
152
+ function collectMacroImportNodes(imports) {
153
+ const result = new Map();
154
+
155
+ for (const source of MACRO_SOURCES) {
156
+ const importInfo = imports.get(source);
157
+ if (importInfo) {
158
+ result.set(source, importInfo.node);
159
+ }
160
+ }
161
+
162
+ return result;
163
+ }
164
+
165
+ // ---------------------------------------------------------------------------
166
+ // PropertyDefinition analysis (native class decorators)
167
+ // ---------------------------------------------------------------------------
168
+
169
+ /**
170
+ * Analyze a PropertyDefinition node for macro decorator usage.
171
+ *
172
+ * @param {import('estree').Node} node - PropertyDefinition
173
+ * @param {Map<string, string>} importedMacros
174
+ * @param {MacroUsage[]} usages
175
+ * @param {import('eslint').SourceCode} sourceCode
176
+ */
177
+ function analyzePropertyDefinition(node, importedMacros, usages, sourceCode) {
178
+ for (const decorator of node.decorators) {
179
+ const expr = decorator.expression;
180
+
181
+ // Must be a CallExpression — e.g. @alias("foo")
182
+ if (expr.type !== "CallExpression") {
183
+ continue;
184
+ }
185
+
186
+ const callee = expr.callee;
187
+ if (callee.type !== "Identifier") {
188
+ continue;
189
+ }
190
+
191
+ const macroName = importedMacros.get(callee.name);
192
+ if (!macroName) {
193
+ continue;
194
+ }
195
+
196
+ const transform = MACRO_TRANSFORMS.get(macroName);
197
+ if (!transform) {
198
+ continue;
199
+ }
200
+
201
+ const propName =
202
+ node.key.type === "Identifier" ? node.key.name : String(node.key.value);
203
+
204
+ const usage = buildUsage({
205
+ macroName,
206
+ localName: callee.name,
207
+ transform,
208
+ decoratorNode: decorator,
209
+ propertyNode: node,
210
+ argNodes: expr.arguments,
211
+ propName,
212
+ sourceCode,
213
+ });
214
+
215
+ usages.push(usage);
216
+ }
217
+ }
218
+
219
+ // ---------------------------------------------------------------------------
220
+ // Usage builder
221
+ // ---------------------------------------------------------------------------
222
+
223
+ /**
224
+ * Build a MacroUsage object from the gathered information, determining
225
+ * fixability and computing dependent keys.
226
+ *
227
+ * @param {Object} params
228
+ * @returns {MacroUsage}
229
+ */
230
+ function buildUsage({
231
+ macroName,
232
+ localName,
233
+ transform,
234
+ decoratorNode,
235
+ propertyNode,
236
+ argNodes,
237
+ propName,
238
+ sourceCode,
239
+ }) {
240
+ const base = {
241
+ macroName,
242
+ localName,
243
+ transform,
244
+ decoratorNode,
245
+ propertyNode,
246
+ argNodes,
247
+ propName,
248
+ sourceCode,
249
+ };
250
+
251
+ // Not auto-fixable by design (e.g. filter/map with callbacks)
252
+ if (!transform.canAutoFix) {
253
+ return {
254
+ ...base,
255
+ literalArgs: [],
256
+ dependentKeys: [],
257
+ allLocal: false,
258
+ canAutoFix: false,
259
+ messageId: "cannotAutoFixComplex",
260
+ reportData: { name: macroName, reason: transform.reason },
261
+ };
262
+ }
263
+
264
+ // Extract literal arguments; bail if any dep-key arg is non-literal.
265
+ // Args beyond `depKeyArgCount` are "value args" — they can be non-literal
266
+ // (their source text is used verbatim in the getter body via sourceCode.getText).
267
+ const literalArgs = [];
268
+ for (let i = 0; i < argNodes.length; i++) {
269
+ const argNode = argNodes[i];
270
+
271
+ // Value args (beyond dep key positions) don't need to be literals
272
+ if (transform.depKeyArgCount != null && i >= transform.depKeyArgCount) {
273
+ literalArgs.push(null);
274
+ continue;
275
+ }
276
+
277
+ if (argNode.type === "Literal" && typeof argNode.value === "string") {
278
+ literalArgs.push(argNode.value);
279
+ } else if (
280
+ argNode.type === "Literal" &&
281
+ (typeof argNode.value === "number" || typeof argNode.value === "boolean")
282
+ ) {
283
+ literalArgs.push(argNode.value);
284
+ } else if (argNode.type === "Literal" && argNode.regex) {
285
+ // Regex literal — allowed for `match`
286
+ literalArgs.push(argNode.raw);
287
+ } else if (
288
+ argNode.type === "TemplateLiteral" &&
289
+ argNode.expressions.length === 0
290
+ ) {
291
+ // Static template literal like `foo`
292
+ literalArgs.push(argNode.quasis[0].value.cooked);
293
+ } else {
294
+ // Non-literal argument → can't auto-fix
295
+ return {
296
+ ...base,
297
+ literalArgs,
298
+ dependentKeys: [],
299
+ allLocal: false,
300
+ canAutoFix: false,
301
+ messageId: "cannotAutoFixDynamic",
302
+ reportData: { name: macroName },
303
+ };
304
+ }
305
+ }
306
+
307
+ // Build transform args
308
+ const transformArgs = { literalArgs, argNodes, propName, sourceCode };
309
+
310
+ // Compute dependent keys
311
+ const dependentKeys = transform.toDependentKeys(transformArgs);
312
+
313
+ // Check for self-referencing getter
314
+ const depPaths = dependentKeys.map((k) => k.split(".")[0]);
315
+ if (depPaths.includes(propName)) {
316
+ return {
317
+ ...base,
318
+ literalArgs,
319
+ dependentKeys,
320
+ allLocal: false,
321
+ canAutoFix: false,
322
+ messageId: "cannotAutoFixSelfReference",
323
+ reportData: { name: macroName, propName },
324
+ };
325
+ }
326
+
327
+ const allLocal = dependentKeys.every(isLocalKey);
328
+
329
+ // Determine which local deps need @tracked
330
+ let trackedDeps;
331
+ let existingNodesToDecorate;
332
+ if (allLocal) {
333
+ const info = findDepsNeedingTracked(propertyNode, dependentKeys);
334
+ trackedDeps = info.depsToInsert;
335
+ existingNodesToDecorate = info.existingNodesToDecorate;
336
+ }
337
+
338
+ return {
339
+ ...base,
340
+ literalArgs,
341
+ dependentKeys,
342
+ allLocal,
343
+ canAutoFix: true,
344
+ trackedDeps,
345
+ existingNodesToDecorate,
346
+ };
347
+ }
348
+
349
+ // ---------------------------------------------------------------------------
350
+ // Tracked dep post-processing
351
+ // ---------------------------------------------------------------------------
352
+
353
+ /**
354
+ * Remove tracked deps and nodes-to-decorate that correspond to other macro
355
+ * usages being converted to getters in the same class. After conversion,
356
+ * those properties will be getters (reactive by nature), so adding `@tracked`
357
+ * would create a duplicate class member.
358
+ *
359
+ * @param {MacroUsage[]} usages
360
+ */
361
+ function excludeDepsBeingConverted(usages) {
362
+ // Group fixable usage prop names and property nodes by ClassBody
363
+ const convertedByClass = new Map();
364
+ for (const usage of usages) {
365
+ if (!usage.canAutoFix) {
366
+ continue;
367
+ }
368
+ const classBody = usage.propertyNode.parent;
369
+ if (!convertedByClass.has(classBody)) {
370
+ convertedByClass.set(classBody, { names: new Set(), nodes: new Set() });
371
+ }
372
+ const entry = convertedByClass.get(classBody);
373
+ entry.names.add(usage.propName);
374
+ entry.nodes.add(usage.propertyNode);
375
+ }
376
+
377
+ for (const usage of usages) {
378
+ const classBody = usage.propertyNode?.parent;
379
+ const converted = convertedByClass.get(classBody);
380
+ if (!converted) {
381
+ continue;
382
+ }
383
+
384
+ if (usage.trackedDeps) {
385
+ usage.trackedDeps = usage.trackedDeps.filter(
386
+ (dep) => !converted.names.has(dep)
387
+ );
388
+ }
389
+ if (usage.existingNodesToDecorate) {
390
+ usage.existingNodesToDecorate = usage.existingNodesToDecorate.filter(
391
+ (node) => !converted.nodes.has(node)
392
+ );
393
+ }
394
+ }
395
+ }
396
+
397
+ // ---------------------------------------------------------------------------
398
+ // Implicit injection exclusion
399
+ // ---------------------------------------------------------------------------
400
+
401
+ // Property names implicitly injected into Ember framework classes by
402
+ // Discourse's registerDiscourseImplicitInjections() (see
403
+ // discourse/app/lib/implicit-injections.js). Adding @tracked for an
404
+ // undeclared property with one of these names would shadow the inherited
405
+ // injection with undefined.
406
+ const IMPLICIT_INJECTION_NAMES = new Set([
407
+ // commonInjections (Controller, Component, Route, RestModel, RestAdapter)
408
+ "appEvents",
409
+ "pmTopicTrackingState",
410
+ "store",
411
+ "site",
412
+ "searchService",
413
+ "session",
414
+ "messageBus",
415
+ "siteSettings",
416
+ "topicTrackingState",
417
+ "keyValueStore",
418
+ // Controller, Component, Route extras
419
+ "capabilities",
420
+ "currentUser",
421
+ ]);
422
+
423
+ /**
424
+ * Promote usages to `@computed` when any of their tracked deps (new
425
+ * declarations, not existing members) match a known implicitly-injected
426
+ * property name. Inserting `@tracked currentUser;` in a controller that
427
+ * inherits `currentUser` via implicit injection would shadow the injection
428
+ * with `undefined`.
429
+ *
430
+ * @param {MacroUsage[]} usages
431
+ */
432
+ function excludeImplicitInjectionDeps(usages) {
433
+ for (const usage of usages) {
434
+ if (!usage.canAutoFix || !usage.allLocal || !usage.trackedDeps) {
435
+ continue;
436
+ }
437
+
438
+ const hasImplicitDep = usage.trackedDeps.some((dep) =>
439
+ IMPLICIT_INJECTION_NAMES.has(dep)
440
+ );
441
+
442
+ if (hasImplicitDep) {
443
+ usage.allLocal = false;
444
+ usage.trackedDeps = undefined;
445
+ usage.existingNodesToDecorate = undefined;
446
+ }
447
+ }
448
+ }
449
+
450
+ // ---------------------------------------------------------------------------
451
+ // Classic component detection
452
+ // ---------------------------------------------------------------------------
453
+
454
+ // Decorators from @ember-decorators/component that are exclusively used on
455
+ // classic Ember components (those extending @ember/component).
456
+ const CLASSIC_COMPONENT_DECORATORS = new Set([
457
+ "classNames",
458
+ "classNameBindings",
459
+ "tagName",
460
+ "attributeBindings",
461
+ ]);
462
+
463
+ /**
464
+ * Determine whether a class declaration represents a classic Ember component.
465
+ *
466
+ * Classic components use Ember's two-way binding system and cannot use
467
+ * `@tracked` + `@dependentKeyCompat` — they must use `@computed` instead.
468
+ *
469
+ * Detection signals:
470
+ * 1. Direct: superclass is `Component` imported from `@ember/component`
471
+ * 2. Decorator: class has decorators from `@ember-decorators/component`
472
+ * 3. Naming: superclass name ends with "Component" (and is NOT from `@glimmer/component`)
473
+ *
474
+ * @param {import('estree').Node} classNode - ClassDeclaration or ClassExpression
475
+ * @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} importsMap
476
+ * @returns {boolean}
477
+ */
478
+ function isClassicComponent(classNode, importsMap) {
479
+ const superClass = classNode.superClass;
480
+ if (!superClass) {
481
+ return false;
482
+ }
483
+
484
+ // 1. Direct: extends Component from @ember/component
485
+ if (superClass.type === "Identifier") {
486
+ const componentImport = importsMap.get("@ember/component");
487
+ if (componentImport) {
488
+ const defaultSpec = componentImport.specifiers.find(
489
+ (s) => s.type === "ImportDefaultSpecifier"
490
+ );
491
+ if (defaultSpec && defaultSpec.local.name === superClass.name) {
492
+ return true;
493
+ }
494
+ }
495
+
496
+ // Explicit exclusion: @glimmer/component is NOT classic
497
+ const glimmerImport = importsMap.get("@glimmer/component");
498
+ if (glimmerImport) {
499
+ const defaultSpec = glimmerImport.specifiers.find(
500
+ (s) => s.type === "ImportDefaultSpecifier"
501
+ );
502
+ if (defaultSpec && defaultSpec.local.name === superClass.name) {
503
+ return false;
504
+ }
505
+ }
506
+ }
507
+
508
+ // 2. Decorator signal: @classNames, @tagName, etc. from @ember-decorators/component
509
+ if (classNode.decorators?.length > 0) {
510
+ const emberDecImport = importsMap.get("@ember-decorators/component");
511
+ if (emberDecImport) {
512
+ const importedNames = new Set(
513
+ emberDecImport.specifiers
514
+ .filter(
515
+ (s) =>
516
+ s.type === "ImportSpecifier" &&
517
+ CLASSIC_COMPONENT_DECORATORS.has(s.imported.name)
518
+ )
519
+ .map((s) => s.local.name)
520
+ );
521
+ const hasClassicDecorator = classNode.decorators.some((d) => {
522
+ const expr = d.expression;
523
+ const name =
524
+ expr.type === "Identifier"
525
+ ? expr.name
526
+ : expr.type === "CallExpression" &&
527
+ expr.callee?.type === "Identifier"
528
+ ? expr.callee.name
529
+ : null;
530
+ return name && importedNames.has(name);
531
+ });
532
+ if (hasClassicDecorator) {
533
+ return true;
534
+ }
535
+ }
536
+ }
537
+
538
+ // 3. Naming convention: superclass name ends with "Component"
539
+ const superName =
540
+ superClass.type === "Identifier"
541
+ ? superClass.name
542
+ : superClass.type === "MemberExpression"
543
+ ? superClass.property?.name
544
+ : null;
545
+ if (superName?.endsWith("Component")) {
546
+ return true;
547
+ }
548
+
549
+ return false;
550
+ }
551
+
552
+ /**
553
+ * Force `@computed` (instead of `@dependentKeyCompat` + `@tracked`) for all
554
+ * fixable macros inside classic Ember components.
555
+ *
556
+ * Classic components rely on Ember's two-way binding system. Adding `@tracked`
557
+ * bypasses classic property notifications and breaks template bindings. Using
558
+ * `@computed` keeps everything within the classic property system.
559
+ *
560
+ * Must run before `propagateComputedRequirement` so that forced-computed macros
561
+ * seed the transitive propagation.
562
+ *
563
+ * @param {MacroUsage[]} usages
564
+ * @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} importsMap
565
+ */
566
+ function forceComputedForClassicComponents(usages, importsMap) {
567
+ for (const usage of usages) {
568
+ if (!usage.canAutoFix || !usage.allLocal) {
569
+ continue;
570
+ }
571
+
572
+ const classBody = usage.propertyNode.parent;
573
+ const classNode = classBody.parent;
574
+ if (isClassicComponent(classNode, importsMap)) {
575
+ usage.allLocal = false;
576
+ usage.trackedDeps = undefined;
577
+ usage.existingNodesToDecorate = undefined;
578
+ }
579
+ }
580
+ }
581
+
582
+ // ---------------------------------------------------------------------------
583
+ // Undeclared dep exclusion for subclasses
584
+ // ---------------------------------------------------------------------------
585
+
586
+ /**
587
+ * Promote usages to `@computed` when a class extends an unknown (non-framework)
588
+ * superclass and the fixer would insert NEW `@tracked` declarations for deps
589
+ * not found in the current class body.
590
+ *
591
+ * Without cross-file analysis we cannot know whether an undeclared dep is
592
+ * genuinely new local state or an inherited property (computed getter,
593
+ * injection, etc.) from the parent class. Inserting `@tracked propName;`
594
+ * for an inherited property shadows it with `undefined`, breaking runtime
595
+ * behavior. Promoting to `@computed` uses Ember's string-based observation
596
+ * which works correctly through the prototype chain.
597
+ *
598
+ * Classes extending known Ember/Glimmer framework base classes (`@ember/*`,
599
+ * `@glimmer/*`, `ember-data/*`) are safe — their APIs are well-documented and
600
+ * the `IMPLICIT_INJECTION_NAMES` exclusion already handles their dangerous
601
+ * properties.
602
+ *
603
+ * Deps that ARE declared in the current class body (`existingNodesToDecorate`)
604
+ * are safe — the property is visibly local, so there is no shadowing risk.
605
+ *
606
+ * Must run after `forceComputedForClassicComponents` (so classic component
607
+ * usages already have `trackedDeps` cleared) and before
608
+ * `propagateComputedRequirement`.
609
+ *
610
+ * @param {MacroUsage[]} usages
611
+ * @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} importsMap
612
+ */
613
+ function excludeUndeclaredDepsInSubclasses(usages, importsMap) {
614
+ for (const usage of usages) {
615
+ if (!usage.canAutoFix || !usage.allLocal || !usage.trackedDeps?.length) {
616
+ continue;
617
+ }
618
+
619
+ const classNode = usage.propertyNode.parent.parent;
620
+ if (!classNode.superClass) {
621
+ continue;
622
+ }
623
+
624
+ // Known Ember/Glimmer framework classes have well-documented APIs.
625
+ // IMPLICIT_INJECTION_NAMES already handles their dangerous properties.
626
+ if (isKnownFrameworkSuperclass(classNode, importsMap)) {
627
+ continue;
628
+ }
629
+
630
+ // Unknown superclass — could define computed getters or other
631
+ // properties we'd shadow. Promote to @computed for safety.
632
+ usage.allLocal = false;
633
+ usage.trackedDeps = undefined;
634
+ usage.existingNodesToDecorate = undefined;
635
+ }
636
+ }
637
+
638
+ /**
639
+ * Check whether a class's superclass is a default import from a known
640
+ * Ember/Glimmer framework package (`@ember/*`, `@glimmer/*`, `ember-data/*`).
641
+ *
642
+ * @param {import('estree').Node} classNode - ClassDeclaration or ClassExpression
643
+ * @param {Map<string, {node: import('estree').ImportDeclaration, specifiers: Array}>} importsMap
644
+ * @returns {boolean}
645
+ */
646
+ function isKnownFrameworkSuperclass(classNode, importsMap) {
647
+ const superClass = classNode.superClass;
648
+ if (!superClass || superClass.type !== "Identifier") {
649
+ return false;
650
+ }
651
+
652
+ for (const [source, importInfo] of importsMap) {
653
+ if (
654
+ !source.startsWith("@ember/") &&
655
+ !source.startsWith("@glimmer/") &&
656
+ !source.startsWith("ember-data")
657
+ ) {
658
+ continue;
659
+ }
660
+
661
+ const defaultSpec = importInfo.specifiers.find(
662
+ (s) => s.type === "ImportDefaultSpecifier"
663
+ );
664
+ if (defaultSpec && defaultSpec.local.name === superClass.name) {
665
+ return true;
666
+ }
667
+ }
668
+
669
+ return false;
670
+ }
671
+
672
+ /**
673
+ * Propagate the @computed requirement transitively through macro dependencies.
674
+ *
675
+ * A `@dependentKeyCompat` getter cannot observe a `@computed` getter. So when
676
+ * a fixable macro depends on a `@computed` getter — either another macro being
677
+ * converted or an *existing* `@computed` getter already in the class — the macro
678
+ * must also use `@computed`. This propagation is transitive.
679
+ *
680
+ * For each promoted macro we clear `trackedDeps` and `existingNodesToDecorate`
681
+ * because `@computed` getters don't require `@tracked` on their deps.
682
+ *
683
+ * @param {MacroUsage[]} usages
684
+ */
685
+ function propagateComputedRequirement(usages) {
686
+ const classBuckets = new Map();
687
+ for (const usage of usages) {
688
+ if (!usage.canAutoFix) {
689
+ continue;
690
+ }
691
+ const classBody = usage.propertyNode.parent;
692
+ if (!classBuckets.has(classBody)) {
693
+ classBuckets.set(classBody, []);
694
+ }
695
+ classBuckets.get(classBody).push(usage);
696
+ }
697
+
698
+ for (const classUsages of classBuckets.values()) {
699
+ const classBody = classUsages[0].propertyNode.parent;
700
+
701
+ // Seed from macros being converted that already have nested deps
702
+ const computedPropNames = new Set();
703
+ for (const usage of classUsages) {
704
+ if (!usage.allLocal) {
705
+ computedPropNames.add(usage.propName);
706
+ }
707
+ }
708
+
709
+ // Seed from existing @computed getters already in the class
710
+ for (const member of classBody.body) {
711
+ if (member.type === "MethodDefinition" && hasComputedDecorator(member)) {
712
+ const name =
713
+ member.key.type === "Identifier"
714
+ ? member.key.name
715
+ : String(member.key.value);
716
+ computedPropNames.add(name);
717
+ }
718
+ }
719
+
720
+ if (computedPropNames.size === 0) {
721
+ continue;
722
+ }
723
+
724
+ // Fixed-point loop: keep propagating until no new promotions occur
725
+ let changed = true;
726
+ while (changed) {
727
+ changed = false;
728
+ for (const usage of classUsages) {
729
+ if (!usage.allLocal) {
730
+ continue;
731
+ }
732
+ if (usage.dependentKeys.some((k) => computedPropNames.has(k))) {
733
+ usage.allLocal = false;
734
+ usage.trackedDeps = undefined;
735
+ usage.existingNodesToDecorate = undefined;
736
+ computedPropNames.add(usage.propName);
737
+ changed = true;
738
+ }
739
+ }
740
+ }
741
+ }
742
+ }
743
+
744
+ /**
745
+ * Check whether a class member has a `@computed` decorator.
746
+ *
747
+ * @param {import('estree').Node} member
748
+ * @returns {boolean}
749
+ */
750
+ function hasComputedDecorator(member) {
751
+ return (
752
+ member.decorators?.some((d) => {
753
+ const expr = d.expression;
754
+ return (
755
+ (expr.type === "Identifier" && expr.name === "computed") ||
756
+ (expr.type === "CallExpression" &&
757
+ expr.callee?.type === "Identifier" &&
758
+ expr.callee.name === "computed")
759
+ );
760
+ }) ?? false
761
+ );
762
+ }
763
+
764
+ /**
765
+ * Ensure each new `@tracked` dep name is assigned to at most ONE usage.
766
+ * Without this, two macros referencing the same dep would both try to
767
+ * insert `@tracked propName;` (since new declarations are prepended to
768
+ * each property replacement to avoid range overlaps).
769
+ *
770
+ * Note: `existingNodesToDecorate` is NOT deduplicated here — those are
771
+ * aggregated centrally in the import-level fix via a Set.
772
+ *
773
+ * @param {MacroUsage[]} usages
774
+ */
775
+ function deduplicateTrackedDeps(usages) {
776
+ const claimedDeps = new Set();
777
+
778
+ for (const usage of usages) {
779
+ if (usage.trackedDeps) {
780
+ usage.trackedDeps = usage.trackedDeps.filter((dep) => {
781
+ if (claimedDeps.has(dep)) {
782
+ return false;
783
+ }
784
+ claimedDeps.add(dep);
785
+ return true;
786
+ });
787
+ }
788
+ }
789
+ }
790
+
791
+ // ---------------------------------------------------------------------------
792
+ // @tracked dependency detection
793
+ // ---------------------------------------------------------------------------
794
+
795
+ /**
796
+ * @typedef {Object} TrackedDepsInfo
797
+ * @property {string[]} depsToInsert - Deps that need a NEW `@tracked propName;` declaration
798
+ * @property {import('estree').Node[]} existingNodesToDecorate - Existing PropertyDefinition
799
+ * nodes that need `@tracked` added as a decorator
800
+ */
801
+
802
+ /**
803
+ * Given a PropertyDefinition inside a class body, determine which local
804
+ * dependent keys need `@tracked` handling:
805
+ *
806
+ * - Keys matching a MethodDefinition (getter/method) → already reactive, skip
807
+ * - Keys declared as PropertyDefinition with any decorator → already managed
808
+ * by something (@service, @tracked, @inject, etc.), skip
809
+ * - Keys declared as PropertyDefinition without decorators → need `@tracked` added
810
+ * - Keys not declared as any class member → need a new `@tracked propName;` inserted
811
+ *
812
+ * @param {import('estree').Node} propertyNode - The PropertyDefinition node
813
+ * @param {string[]} dependentKeys - Local-only dependent keys (no dots)
814
+ * @returns {TrackedDepsInfo}
815
+ */
816
+ function findDepsNeedingTracked(propertyNode, dependentKeys) {
817
+ const classBody = propertyNode.parent;
818
+ if (!classBody || classBody.type !== "ClassBody") {
819
+ return { depsToInsert: [...dependentKeys], existingNodesToDecorate: [] };
820
+ }
821
+
822
+ const reactiveMembers = new Set(); // getters, methods, and decorated properties
823
+ const untrackedMemberNodes = new Map(); // name → AST node
824
+
825
+ for (const member of classBody.body) {
826
+ // Getters and methods are already reactive — no @tracked needed
827
+ if (member.type === "MethodDefinition") {
828
+ const name =
829
+ member.key.type === "Identifier"
830
+ ? member.key.name
831
+ : String(member.key.value);
832
+ reactiveMembers.add(name);
833
+ continue;
834
+ }
835
+
836
+ if (member.type !== "PropertyDefinition") {
837
+ continue;
838
+ }
839
+
840
+ const name =
841
+ member.key.type === "Identifier"
842
+ ? member.key.name
843
+ : String(member.key.value);
844
+
845
+ // Any decorated property is already managed by its decorator
846
+ // (@tracked, @service, @inject, etc.) — adding @tracked would be wrong.
847
+ if (member.decorators?.length > 0) {
848
+ reactiveMembers.add(name);
849
+ } else {
850
+ untrackedMemberNodes.set(name, member);
851
+ }
852
+ }
853
+
854
+ const depsToInsert = [];
855
+ const existingNodesToDecorate = [];
856
+
857
+ for (const key of dependentKeys) {
858
+ if (reactiveMembers.has(key)) {
859
+ continue; // already reactive (getter, method, or decorated property)
860
+ }
861
+ if (untrackedMemberNodes.has(key)) {
862
+ existingNodesToDecorate.push(untrackedMemberNodes.get(key));
863
+ } else {
864
+ depsToInsert.push(key);
865
+ }
866
+ }
867
+
868
+ return { depsToInsert, existingNodesToDecorate };
869
+ }
870
+
871
+ // ---------------------------------------------------------------------------
872
+ // AST walker
873
+ // ---------------------------------------------------------------------------
874
+
875
+ /**
876
+ * Simple recursive AST walker that calls `visitor(node)` for every node.
877
+ *
878
+ * @param {import('estree').Node} node
879
+ * @param {(node: import('estree').Node) => void} visitor
880
+ */
881
+ function walkNode(node, visitor) {
882
+ if (!node || typeof node !== "object") {
883
+ return;
884
+ }
885
+
886
+ visitor(node);
887
+
888
+ for (const key in node) {
889
+ if (key === "parent" || key === "range" || key === "loc") {
890
+ continue;
891
+ }
892
+ const child = node[key];
893
+ if (Array.isArray(child)) {
894
+ for (const item of child) {
895
+ if (item && typeof item.type === "string") {
896
+ walkNode(item, visitor);
897
+ }
898
+ }
899
+ } else if (child && typeof child.type === "string") {
900
+ walkNode(child, visitor);
901
+ }
902
+ }
903
+ }