Skip to content

Commit 6d34392

Browse files
Merge pull request #2551 from ember-cli/copilot/add-lint-to-autofix-built-ins
Add `no-tracked-built-ins` rule and ember-source version utility
2 parents f543286 + 78ecc5e commit 6d34392

8 files changed

Lines changed: 594 additions & 1 deletion

File tree

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,3 +14,6 @@ npm-debug.log
1414

1515
# eslint-remote-tester
1616
eslint-remote-tester-results
17+
18+
# Lock file generated by npm install (project uses pnpm)
19+
package-lock.json

README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -337,6 +337,7 @@ rules in templates can be disabled with eslint directives with mustache or html
337337
| [no-classic-classes](docs/rules/no-classic-classes.md) | disallow "classic" classes in favor of native JS classes || | |
338338
| [no-ember-super-in-es-classes](docs/rules/no-ember-super-in-es-classes.md) | disallow use of `this._super` in ES class methods || 🔧 | |
339339
| [no-empty-glimmer-component-classes](docs/rules/no-empty-glimmer-component-classes.md) | disallow empty backing classes for Glimmer components || | |
340+
| [no-tracked-built-ins](docs/rules/no-tracked-built-ins.md) | enforce usage of `@ember/reactive/collections` imports instead of `tracked-built-ins` | | 🔧 | |
340341
| [no-tracked-properties-from-args](docs/rules/no-tracked-properties-from-args.md) | disallow creating @tracked properties from this.args || | |
341342
| [template-indent](docs/rules/template-indent.md) | enforce consistent indentation for gts/gjs templates | | 🔧 | |
342343
| [template-no-deprecated](docs/rules/template-no-deprecated.md) | disallow using deprecated Glimmer components, helpers, and modifiers in templates | | | |

docs/rules/no-tracked-built-ins.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# ember/no-tracked-built-ins
2+
3+
🔧 This rule is automatically fixable by the [`--fix` CLI option](https://eslint.org/docs/latest/user-guide/command-line-interface#--fix).
4+
5+
<!-- end auto-generated rule header -->
6+
7+
Enforce usage of `@ember/reactive/collections` imports instead of `tracked-built-ins`.
8+
9+
## Context
10+
11+
Per [RFC #1068](https://github.com/emberjs/rfcs/pull/1068), the tracked collection utilities from the `tracked-built-ins` package are being moved into the framework as `@ember/reactive/collections`. The new API also changes from class constructors (`new TrackedArray(...)`) to factory functions (`trackedArray(...)`).
12+
13+
## Rule Details
14+
15+
This rule detects imports from `tracked-built-ins` and provides an autofix to convert them to `@ember/reactive/collections` with the new function-based API.
16+
17+
The following mappings are applied:
18+
19+
| Old (`tracked-built-ins`) | New (`@ember/reactive/collections`) |
20+
| ------------------------- | ----------------------------------- |
21+
| `TrackedArray` | `trackedArray` |
22+
| `TrackedObject` | `trackedObject` |
23+
| `TrackedMap` | `trackedMap` |
24+
| `TrackedSet` | `trackedSet` |
25+
| `TrackedWeakMap` | `trackedWeakMap` |
26+
| `TrackedWeakSet` | `trackedWeakSet` |
27+
28+
Additionally, `new` expressions using these imports are automatically converted to direct function calls.
29+
30+
## Examples
31+
32+
Examples of **incorrect** code for this rule:
33+
34+
```js
35+
import { TrackedArray } from 'tracked-built-ins';
36+
37+
const arr = new TrackedArray([1, 2, 3]);
38+
```
39+
40+
```js
41+
import { TrackedObject, TrackedMap } from 'tracked-built-ins';
42+
43+
const obj = new TrackedObject({ a: 1 });
44+
const map = new TrackedMap();
45+
```
46+
47+
Examples of **correct** code for this rule:
48+
49+
```js
50+
import { trackedArray } from '@ember/reactive/collections';
51+
52+
const arr = trackedArray([1, 2, 3]);
53+
```
54+
55+
```js
56+
import { trackedObject, trackedMap } from '@ember/reactive/collections';
57+
58+
const obj = trackedObject({ a: 1 });
59+
const map = trackedMap();
60+
```
61+
62+
## Migration
63+
64+
This rule provides automatic fixes via `--fix`. Running ESLint with the `--fix` flag will:
65+
66+
1. Replace `import { TrackedArray } from 'tracked-built-ins'` with `import { trackedArray } from '@ember/reactive/collections'`
67+
2. Replace `new TrackedArray(...)` with `trackedArray(...)`
68+
69+
## References
70+
71+
- [RFC #1068: Built in tracking utilities for common collections](https://github.com/emberjs/rfcs/pull/1068)
72+
- [`tracked-built-ins` package](https://github.com/tracked-tools/tracked-built-ins)

lib/recommended-rules.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -75,4 +75,4 @@ module.exports = {
7575
"ember/routes-segments-snake-case": "error",
7676
"ember/use-brace-expansion": "error",
7777
"ember/use-ember-data-rfc-395-imports": "error"
78-
}
78+
};

lib/rules/no-tracked-built-ins.js

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
'use strict';
2+
3+
const emberSourceVersion = require('../utils/ember-source-version');
4+
5+
//------------------------------------------------------------------------------
6+
// Mapping from tracked-built-ins exports to @ember/reactive/collections exports
7+
//------------------------------------------------------------------------------
8+
9+
const TRACKED_BUILT_INS_MAPPING = {
10+
TrackedArray: 'trackedArray',
11+
TrackedObject: 'trackedObject',
12+
TrackedMap: 'trackedMap',
13+
TrackedWeakMap: 'trackedWeakMap',
14+
TrackedSet: 'trackedSet',
15+
TrackedWeakSet: 'trackedWeakSet',
16+
};
17+
18+
const TRACKED_BUILT_INS_MODULE = 'tracked-built-ins';
19+
const EMBER_REACTIVE_MODULE = '@ember/reactive/collections';
20+
21+
const ERROR_MESSAGE_IMPORT =
22+
'Use imports from `@ember/reactive/collections` instead of `tracked-built-ins`.';
23+
24+
//------------------------------------------------------------------------------
25+
// Rule Definition
26+
//------------------------------------------------------------------------------
27+
28+
/** @type {import('eslint').Rule.RuleModule} */
29+
module.exports = {
30+
meta: {
31+
type: 'suggestion',
32+
docs: {
33+
description:
34+
'enforce usage of `@ember/reactive/collections` imports instead of `tracked-built-ins`',
35+
category: 'Ember Octane',
36+
recommended: false,
37+
url: 'https://github.com/ember-cli/eslint-plugin-ember/tree/master/docs/rules/no-tracked-built-ins.md',
38+
},
39+
fixable: 'code',
40+
schema: [],
41+
messages: {
42+
import: ERROR_MESSAGE_IMPORT,
43+
newExpression:
44+
'Use `{{newName}}(...)` instead of `new {{oldName}}(...)`. The `@ember/reactive/collections` utilities do not use `new`.',
45+
},
46+
},
47+
48+
ERROR_MESSAGE_IMPORT,
49+
50+
create(context) {
51+
// Only report when ember-source >= 6.8 (which provides @ember/reactive/collections)
52+
if (!emberSourceVersion.isEmberSourceVersionAtLeast(6, 8)) {
53+
return {};
54+
}
55+
56+
// Track which imported identifiers map to tracked-built-ins classes
57+
// so we can fix `new TrackedArray(...)` → `trackedArray(...)`
58+
const trackedIdentifiers = new Map();
59+
60+
return {
61+
ImportDeclaration(node) {
62+
if (node.source.value !== TRACKED_BUILT_INS_MODULE) {
63+
return;
64+
}
65+
66+
context.report({
67+
node,
68+
messageId: 'import',
69+
fix(fixer) {
70+
const specifiers = node.specifiers;
71+
72+
// Only autofix named imports we know how to map
73+
const namedSpecifiers = specifiers.filter(
74+
(s) => s.type === 'ImportSpecifier' && s.imported.name in TRACKED_BUILT_INS_MAPPING
75+
);
76+
77+
// If there's a default import or unknown named imports, we can't fully autofix
78+
const hasDefault = specifiers.some((s) => s.type === 'ImportDefaultSpecifier');
79+
const unknownNamed = specifiers.filter(
80+
(s) => s.type === 'ImportSpecifier' && !(s.imported.name in TRACKED_BUILT_INS_MAPPING)
81+
);
82+
83+
if (hasDefault || unknownNamed.length > 0 || namedSpecifiers.length === 0) {
84+
return null;
85+
}
86+
87+
const newSpecifiers = namedSpecifiers.map((s) => {
88+
const newName = TRACKED_BUILT_INS_MAPPING[s.imported.name];
89+
if (s.local.name !== s.imported.name) {
90+
// Has alias: `import { TrackedArray as TA }` → `import { trackedArray as TA }`
91+
return `${newName} as ${s.local.name}`;
92+
}
93+
return newName;
94+
});
95+
96+
const newImport = `import { ${newSpecifiers.join(', ')} } from '${EMBER_REACTIVE_MODULE}';`;
97+
return fixer.replaceText(node, newImport);
98+
},
99+
});
100+
101+
// Register the local names for NewExpression tracking
102+
for (const specifier of node.specifiers) {
103+
if (
104+
specifier.type === 'ImportSpecifier' &&
105+
specifier.imported.name in TRACKED_BUILT_INS_MAPPING
106+
) {
107+
const isAliased = specifier.local.name !== specifier.imported.name;
108+
trackedIdentifiers.set(specifier.local.name, {
109+
newName: TRACKED_BUILT_INS_MAPPING[specifier.imported.name],
110+
isAliased,
111+
});
112+
}
113+
}
114+
},
115+
116+
NewExpression(node) {
117+
if (node.callee.type === 'Identifier' && trackedIdentifiers.has(node.callee.name)) {
118+
const oldName = node.callee.name;
119+
const { newName, isAliased } = trackedIdentifiers.get(oldName);
120+
121+
context.report({
122+
node,
123+
messageId: 'newExpression',
124+
data: { oldName, newName },
125+
fix(fixer) {
126+
const sourceCode = context.getSourceCode();
127+
const newKeyword = sourceCode.getFirstToken(node);
128+
const calleeToken = sourceCode.getTokenAfter(newKeyword);
129+
const fixes = [
130+
// Remove the `new` keyword and any whitespace up to the callee
131+
fixer.removeRange([newKeyword.range[0], calleeToken.range[0]]),
132+
];
133+
// Only rename the callee if it's not aliased
134+
if (!isAliased) {
135+
fixes.push(fixer.replaceText(node.callee, newName));
136+
}
137+
return fixes;
138+
},
139+
});
140+
}
141+
},
142+
};
143+
},
144+
};

lib/utils/ember-source-version.js

Lines changed: 63 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,63 @@
1+
'use strict';
2+
3+
const fs = require('node:fs');
4+
5+
/**
6+
* Get the installed ember-source version by resolving its package.json.
7+
*
8+
* @param {string} [projectRoot] - Project root directory (defaults to process.cwd())
9+
* @returns {string|null} The installed ember-source version, or null if not found
10+
*/
11+
function getEmberSourceVersion(projectRoot) {
12+
try {
13+
// eslint-disable-next-line n/no-missing-require
14+
const pkgPath = require.resolve('ember-source/package.json', {
15+
paths: [projectRoot || process.cwd()],
16+
});
17+
const pkg = JSON.parse(fs.readFileSync(pkgPath, 'utf8'));
18+
return pkg.version || null;
19+
} catch {
20+
return null;
21+
}
22+
}
23+
24+
/**
25+
* Check if a semver version string meets a minimum major.minor requirement.
26+
*
27+
* @param {string} version - Semver version string (e.g. '6.8.0')
28+
* @param {number} major - Required minimum major version
29+
* @param {number} minor - Required minimum minor version
30+
* @returns {boolean} True if version >= major.minor
31+
*/
32+
function isVersionAtLeast(version, major, minor) {
33+
if (!version || typeof version !== 'string') {
34+
return false;
35+
}
36+
37+
const parts = version.split('.');
38+
const vMajor = Number.parseInt(parts[0], 10);
39+
const vMinor = Number.parseInt(parts[1], 10);
40+
41+
if (Number.isNaN(vMajor) || Number.isNaN(vMinor)) {
42+
return false;
43+
}
44+
45+
return vMajor > major || (vMajor === major && vMinor >= minor);
46+
}
47+
48+
/**
49+
* Check if the installed ember-source version meets a minimum major.minor requirement.
50+
*
51+
* @param {number} major - Required minimum major version
52+
* @param {number} minor - Required minimum minor version
53+
* @param {string} [projectRoot] - Project root directory (defaults to process.cwd())
54+
* @returns {boolean} True if installed ember-source version >= major.minor
55+
*/
56+
function isEmberSourceVersionAtLeast(major, minor, projectRoot) {
57+
const version = getEmberSourceVersion(projectRoot);
58+
return isVersionAtLeast(version, major, minor);
59+
}
60+
61+
module.exports = {
62+
isEmberSourceVersionAtLeast,
63+
};

0 commit comments

Comments
 (0)