diff --git a/.changeset/kind-schools-retire.md b/.changeset/kind-schools-retire.md
new file mode 100644
index 00000000..5641af1a
--- /dev/null
+++ b/.changeset/kind-schools-retire.md
@@ -0,0 +1,5 @@
+---
+'eslint-plugin-primer-react': major
+---
+
+[Breaking] Adds `no-unnecessary-components` lint rule and enables it by default. This may raise new (typically autofixable) lint errors in existing codebases.
diff --git a/docs/rules/no-unnecessary-components.md b/docs/rules/no-unnecessary-components.md
new file mode 100644
index 00000000..0062b1a7
--- /dev/null
+++ b/docs/rules/no-unnecessary-components.md
@@ -0,0 +1,69 @@
+# Disallow unnecessary use of `Box` and `Text` components (no-unnecessary-components)
+
+🔧 The `--fix` option on the [ESLint CLI](https://eslint.org/docs/user-guide/command-line-interface#fixing-problems) can
+automatically fix some of the problems reported by this rule.
+
+## Rule details
+
+The [`Box`](https://primer.style/components/box) and [`Text`](https://primer.style/components/text)
+Primer React components are utilities that exist solely to provide access to `sx` or styled-system
+props.
+
+If these props are not being used, plain HTML element provide better performance, simpler code,
+and improved linting support.
+
+This rule is auto-fixable in nearly all cases. Autofixing respects the presence of an `as` prop.
+
+👎 Examples of **incorrect** code for this rule:
+
+```jsx
+/* eslint primer-react/no-unnecessary-components: "error" */
+import {Box, Text} from '@primer/react'
+
+Content
+Content
+Content
+
+Content
+Content
+Content
+```
+
+👍 Examples of **correct** code for this rule:
+
+```jsx
+/* eslint primer-react/no-system-props: "error" */
+import {Box, Text} from '@primer/react'
+
+// Prefer plain HTML elements (autofixable)
+
Content
+Content
+
+
+Content
+Content
+Content
+
+// sx props are allowed
+Content
+Content
+
+// styled-system props are allowed
+Content
+Content
+```
+
+```jsx
+/* eslint primer-react/no-system-props: ["error", {skipImportCheck: false}] */
+import {Box, Text} from '@primer/brand'
+
+// Other components with the same name are allowed
+Content
+Content
+```
+
+## Options
+
+- `skipImportCheck` (default: `false`)
+
+ By default, the rule will only check for incorrect uses of `Box` and `Text` components that are are imported from `@primer/react`. You can disable this behavior (checking all components with these names regardless of import source) by setting `skipImportCheck` to `true`.
diff --git a/jest.config.js b/jest.config.js
new file mode 100644
index 00000000..ebb0133b
--- /dev/null
+++ b/jest.config.js
@@ -0,0 +1,7 @@
+// @ts-check
+
+/** @type {import('jest').Config} **/
+module.exports = {
+ testEnvironment: 'node',
+ testMatch: ['**/__tests__/*.test.js'],
+}
diff --git a/package-lock.json b/package-lock.json
index ffb12286..31c49488 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -10,17 +10,21 @@
"license": "MIT",
"dependencies": {
"@styled-system/props": "^5.1.5",
+ "@typescript-eslint/utils": "7.16.0",
"eslint-plugin-github": "^5.0.1",
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-traverse": "^1.0.0",
"lodash": "^4.17.21",
- "styled-system": "^5.1.5"
+ "styled-system": "^5.1.5",
+ "typescript": "^5.5.3"
},
"devDependencies": {
"@changesets/changelog-github": "^0.5.0",
"@changesets/cli": "^2.16.0",
"@github/markdownlint-github": "^0.6.0",
"@github/prettier-config": "0.0.6",
+ "@types/jest": "^29.5.12",
+ "@typescript-eslint/rule-tester": "7.16.0",
"eslint": "^8.42.0",
"eslint-plugin-prettier": "^5.0.1",
"jest": "^29.7.0",
@@ -2120,10 +2124,22 @@
"@types/istanbul-lib-report": "*"
}
},
+ "node_modules/@types/jest": {
+ "version": "29.5.12",
+ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz",
+ "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "expect": "^29.0.0",
+ "pretty-format": "^29.0.0"
+ }
+ },
"node_modules/@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
- "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA=="
+ "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==",
+ "license": "MIT"
},
"node_modules/@types/json5": {
"version": "0.0.29",
@@ -2199,6 +2215,31 @@
}
}
},
+ "node_modules/@typescript-eslint/eslint-plugin/node_modules/@typescript-eslint/utils": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz",
+ "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==",
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@types/json-schema": "^7.0.12",
+ "@types/semver": "^7.5.0",
+ "@typescript-eslint/scope-manager": "7.1.1",
+ "@typescript-eslint/types": "7.1.1",
+ "@typescript-eslint/typescript-estree": "7.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ }
+ },
"node_modules/@typescript-eslint/eslint-plugin/node_modules/semver": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
@@ -2240,6 +2281,132 @@
}
}
},
+ "node_modules/@typescript-eslint/rule-tester": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/rule-tester/-/rule-tester-7.16.0.tgz",
+ "integrity": "sha512-MLDeDEY8BVZiWkIhtMnEkhiwH7CXDOLKDnHntFZp//WHYdso+1gS/8F60+oTb+Xjw4LkWz4D8sJ4js3ZxMoctA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/typescript-estree": "7.16.0",
+ "@typescript-eslint/utils": "7.16.0",
+ "ajv": "^6.12.6",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "4.6.2",
+ "semver": "^7.6.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "@eslint/eslintrc": ">=2",
+ "eslint": "^8.56.0"
+ }
+ },
+ "node_modules/@typescript-eslint/rule-tester/node_modules/@typescript-eslint/types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz",
+ "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==",
+ "dev": true,
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/rule-tester/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz",
+ "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==",
+ "dev": true,
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/types": "7.16.0",
+ "@typescript-eslint/visitor-keys": "7.16.0",
+ "debug": "^4.3.4",
+ "globby": "^11.1.0",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/rule-tester/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz",
+ "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.16.0",
+ "eslint-visitor-keys": "^3.4.3"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/rule-tester/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/rule-tester/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/rule-tester/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "dev": true,
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@typescript-eslint/scope-manager": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.1.tgz",
@@ -2282,6 +2449,43 @@
}
}
},
+ "node_modules/@typescript-eslint/type-utils/node_modules/@typescript-eslint/utils": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz",
+ "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==",
+ "license": "MIT",
+ "dependencies": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@types/json-schema": "^7.0.12",
+ "@types/semver": "^7.5.0",
+ "@typescript-eslint/scope-manager": "7.1.1",
+ "@typescript-eslint/types": "7.1.1",
+ "@typescript-eslint/typescript-estree": "7.1.1",
+ "semver": "^7.5.4"
+ },
+ "engines": {
+ "node": "^16.0.0 || >=18.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependencies": {
+ "eslint": "^8.56.0"
+ }
+ },
+ "node_modules/@typescript-eslint/type-utils/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "license": "ISC",
+ "bin": {
+ "semver": "bin/semver.js"
+ },
+ "engines": {
+ "node": ">=10"
+ }
+ },
"node_modules/@typescript-eslint/types": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.1.1.tgz",
@@ -2358,20 +2562,18 @@
}
},
"node_modules/@typescript-eslint/utils": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz",
- "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==",
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.0.tgz",
+ "integrity": "sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA==",
+ "license": "MIT",
"dependencies": {
"@eslint-community/eslint-utils": "^4.4.0",
- "@types/json-schema": "^7.0.12",
- "@types/semver": "^7.5.0",
- "@typescript-eslint/scope-manager": "7.1.1",
- "@typescript-eslint/types": "7.1.1",
- "@typescript-eslint/typescript-estree": "7.1.1",
- "semver": "^7.5.4"
+ "@typescript-eslint/scope-manager": "7.16.0",
+ "@typescript-eslint/types": "7.16.0",
+ "@typescript-eslint/typescript-estree": "7.16.0"
},
"engines": {
- "node": "^16.0.0 || >=18.0.0"
+ "node": "^18.18.0 || >=20.0.0"
},
"funding": {
"type": "opencollective",
@@ -2381,13 +2583,110 @@
"eslint": "^8.56.0"
}
},
- "node_modules/@typescript-eslint/utils/node_modules/semver": {
- "version": "7.6.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
- "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/scope-manager": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.0.tgz",
+ "integrity": "sha512-8gVv3kW6n01Q6TrI1cmTZ9YMFi3ucDT7i7aI5lEikk2ebk1AEjrwX8MDTdaX5D7fPXMBLvnsaa0IFTAu+jcfOw==",
+ "license": "MIT",
"dependencies": {
- "lru-cache": "^6.0.0"
+ "@typescript-eslint/types": "7.16.0",
+ "@typescript-eslint/visitor-keys": "7.16.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz",
+ "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==",
+ "license": "MIT",
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/typescript-estree": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz",
+ "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==",
+ "license": "BSD-2-Clause",
+ "dependencies": {
+ "@typescript-eslint/types": "7.16.0",
+ "@typescript-eslint/visitor-keys": "7.16.0",
+ "debug": "^4.3.4",
+ "globby": "^11.1.0",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ },
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ },
+ "peerDependenciesMeta": {
+ "typescript": {
+ "optional": true
+ }
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/@typescript-eslint/visitor-keys": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz",
+ "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==",
+ "license": "MIT",
+ "dependencies": {
+ "@typescript-eslint/types": "7.16.0",
+ "eslint-visitor-keys": "^3.4.3"
},
+ "engines": {
+ "node": "^18.18.0 || >=20.0.0"
+ },
+ "funding": {
+ "type": "opencollective",
+ "url": "https://opencollective.com/typescript-eslint"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "license": "MIT",
+ "dependencies": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "license": "ISC",
+ "dependencies": {
+ "brace-expansion": "^2.0.1"
+ },
+ "engines": {
+ "node": ">=16 || 14 >=14.17"
+ },
+ "funding": {
+ "url": "https://github.com/sponsors/isaacs"
+ }
+ },
+ "node_modules/@typescript-eslint/utils/node_modules/semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "license": "ISC",
"bin": {
"semver": "bin/semver.js"
},
@@ -7721,9 +8020,10 @@
"dev": true
},
"node_modules/ts-api-utils": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz",
- "integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
+ "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==",
+ "license": "MIT",
"engines": {
"node": ">=16"
},
@@ -7857,10 +8157,10 @@
}
},
"node_modules/typescript": {
- "version": "5.4.2",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
- "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
- "peer": true,
+ "version": "5.5.4",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
+ "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q==",
+ "license": "Apache-2.0",
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
@@ -9900,6 +10200,16 @@
"@types/istanbul-lib-report": "*"
}
},
+ "@types/jest": {
+ "version": "29.5.12",
+ "resolved": "https://registry.npmjs.org/@types/jest/-/jest-29.5.12.tgz",
+ "integrity": "sha512-eDC8bTvT/QhYdxJAulQikueigY5AsdBRH2yDKW3yveW7svY3+DzN84/2NUgkw10RTiJbWqZrTtoGVdYlvFJdLw==",
+ "dev": true,
+ "requires": {
+ "expect": "^29.0.0",
+ "pretty-format": "^29.0.0"
+ }
+ },
"@types/json-schema": {
"version": "7.0.15",
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
@@ -9963,6 +10273,20 @@
"ts-api-utils": "^1.0.1"
},
"dependencies": {
+ "@typescript-eslint/utils": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz",
+ "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==",
+ "requires": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@types/json-schema": "^7.0.12",
+ "@types/semver": "^7.5.0",
+ "@typescript-eslint/scope-manager": "7.1.1",
+ "@typescript-eslint/types": "7.1.1",
+ "@typescript-eslint/typescript-estree": "7.1.1",
+ "semver": "^7.5.4"
+ }
+ },
"semver": {
"version": "7.6.0",
"resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
@@ -9985,6 +10309,78 @@
"debug": "^4.3.4"
}
},
+ "@typescript-eslint/rule-tester": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/rule-tester/-/rule-tester-7.16.0.tgz",
+ "integrity": "sha512-MLDeDEY8BVZiWkIhtMnEkhiwH7CXDOLKDnHntFZp//WHYdso+1gS/8F60+oTb+Xjw4LkWz4D8sJ4js3ZxMoctA==",
+ "dev": true,
+ "requires": {
+ "@typescript-eslint/typescript-estree": "7.16.0",
+ "@typescript-eslint/utils": "7.16.0",
+ "ajv": "^6.12.6",
+ "json-stable-stringify-without-jsonify": "^1.0.1",
+ "lodash.merge": "4.6.2",
+ "semver": "^7.6.0"
+ },
+ "dependencies": {
+ "@typescript-eslint/types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz",
+ "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw==",
+ "dev": true
+ },
+ "@typescript-eslint/typescript-estree": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz",
+ "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==",
+ "dev": true,
+ "requires": {
+ "@typescript-eslint/types": "7.16.0",
+ "@typescript-eslint/visitor-keys": "7.16.0",
+ "debug": "^4.3.4",
+ "globby": "^11.1.0",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ }
+ },
+ "@typescript-eslint/visitor-keys": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz",
+ "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==",
+ "dev": true,
+ "requires": {
+ "@typescript-eslint/types": "7.16.0",
+ "eslint-visitor-keys": "^3.4.3"
+ }
+ },
+ "brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "dev": true,
+ "requires": {
+ "balanced-match": "^1.0.0"
+ }
+ },
+ "minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "dev": true,
+ "requires": {
+ "brace-expansion": "^2.0.1"
+ }
+ },
+ "semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==",
+ "dev": true
+ }
+ }
+ },
"@typescript-eslint/scope-manager": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.1.1.tgz",
@@ -10003,6 +10399,27 @@
"@typescript-eslint/utils": "7.1.1",
"debug": "^4.3.4",
"ts-api-utils": "^1.0.1"
+ },
+ "dependencies": {
+ "@typescript-eslint/utils": {
+ "version": "7.1.1",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz",
+ "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==",
+ "requires": {
+ "@eslint-community/eslint-utils": "^4.4.0",
+ "@types/json-schema": "^7.0.12",
+ "@types/semver": "^7.5.0",
+ "@typescript-eslint/scope-manager": "7.1.1",
+ "@typescript-eslint/types": "7.1.1",
+ "@typescript-eslint/typescript-estree": "7.1.1",
+ "semver": "^7.5.4"
+ }
+ },
+ "semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="
+ }
}
},
"@typescript-eslint/types": {
@@ -10052,26 +10469,74 @@
}
},
"@typescript-eslint/utils": {
- "version": "7.1.1",
- "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.1.1.tgz",
- "integrity": "sha512-thOXM89xA03xAE0lW7alstvnyoBUbBX38YtY+zAUcpRPcq9EIhXPuJ0YTv948MbzmKh6e1AUszn5cBFK49Umqg==",
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-7.16.0.tgz",
+ "integrity": "sha512-PqP4kP3hb4r7Jav+NiRCntlVzhxBNWq6ZQ+zQwII1y/G/1gdIPeYDCKr2+dH6049yJQsWZiHU6RlwvIFBXXGNA==",
"requires": {
"@eslint-community/eslint-utils": "^4.4.0",
- "@types/json-schema": "^7.0.12",
- "@types/semver": "^7.5.0",
- "@typescript-eslint/scope-manager": "7.1.1",
- "@typescript-eslint/types": "7.1.1",
- "@typescript-eslint/typescript-estree": "7.1.1",
- "semver": "^7.5.4"
+ "@typescript-eslint/scope-manager": "7.16.0",
+ "@typescript-eslint/types": "7.16.0",
+ "@typescript-eslint/typescript-estree": "7.16.0"
},
"dependencies": {
- "semver": {
- "version": "7.6.0",
- "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.0.tgz",
- "integrity": "sha512-EnwXhrlwXMk9gKu5/flx5sv/an57AkRplG3hTK68W7FRDN+k+OWBj65M7719OkA82XLBxrcX0KSHj+X5COhOVg==",
+ "@typescript-eslint/scope-manager": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-7.16.0.tgz",
+ "integrity": "sha512-8gVv3kW6n01Q6TrI1cmTZ9YMFi3ucDT7i7aI5lEikk2ebk1AEjrwX8MDTdaX5D7fPXMBLvnsaa0IFTAu+jcfOw==",
"requires": {
- "lru-cache": "^6.0.0"
+ "@typescript-eslint/types": "7.16.0",
+ "@typescript-eslint/visitor-keys": "7.16.0"
+ }
+ },
+ "@typescript-eslint/types": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-7.16.0.tgz",
+ "integrity": "sha512-fecuH15Y+TzlUutvUl9Cc2XJxqdLr7+93SQIbcZfd4XRGGKoxyljK27b+kxKamjRkU7FYC6RrbSCg0ALcZn/xw=="
+ },
+ "@typescript-eslint/typescript-estree": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-7.16.0.tgz",
+ "integrity": "sha512-a5NTvk51ZndFuOLCh5OaJBELYc2O3Zqxfl3Js78VFE1zE46J2AaVuW+rEbVkQznjkmlzWsUI15BG5tQMixzZLw==",
+ "requires": {
+ "@typescript-eslint/types": "7.16.0",
+ "@typescript-eslint/visitor-keys": "7.16.0",
+ "debug": "^4.3.4",
+ "globby": "^11.1.0",
+ "is-glob": "^4.0.3",
+ "minimatch": "^9.0.4",
+ "semver": "^7.6.0",
+ "ts-api-utils": "^1.3.0"
+ }
+ },
+ "@typescript-eslint/visitor-keys": {
+ "version": "7.16.0",
+ "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-7.16.0.tgz",
+ "integrity": "sha512-rMo01uPy9C7XxG7AFsxa8zLnWXTF8N3PYclekWSrurvhwiw1eW88mrKiAYe6s53AUY57nTRz8dJsuuXdkAhzCg==",
+ "requires": {
+ "@typescript-eslint/types": "7.16.0",
+ "eslint-visitor-keys": "^3.4.3"
+ }
+ },
+ "brace-expansion": {
+ "version": "2.0.1",
+ "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
+ "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==",
+ "requires": {
+ "balanced-match": "^1.0.0"
}
+ },
+ "minimatch": {
+ "version": "9.0.5",
+ "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz",
+ "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==",
+ "requires": {
+ "brace-expansion": "^2.0.1"
+ }
+ },
+ "semver": {
+ "version": "7.6.3",
+ "resolved": "https://registry.npmjs.org/semver/-/semver-7.6.3.tgz",
+ "integrity": "sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A=="
}
}
},
@@ -13887,9 +14352,9 @@
"dev": true
},
"ts-api-utils": {
- "version": "1.2.1",
- "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.2.1.tgz",
- "integrity": "sha512-RIYA36cJn2WiH9Hy77hdF9r7oEwxAtB/TS9/S4Qd90Ap4z5FSiin5zEiTL44OII1Y3IIlEvxwxFUVgrHSZ/UpA==",
+ "version": "1.3.0",
+ "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-1.3.0.tgz",
+ "integrity": "sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==",
"requires": {}
},
"tsconfig-paths": {
@@ -13984,10 +14449,9 @@
}
},
"typescript": {
- "version": "5.4.2",
- "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.4.2.tgz",
- "integrity": "sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==",
- "peer": true
+ "version": "5.5.4",
+ "resolved": "https://registry.npmjs.org/typescript/-/typescript-5.5.4.tgz",
+ "integrity": "sha512-Mtq29sKDAEYP7aljRgtPOpTvOfbwRWlS6dPRzwjdE+C0R4brX/GUyhHSecbHMFLNBLcJIPt9nl9yG5TZ1weH+Q=="
},
"uc.micro": {
"version": "2.1.0",
diff --git a/package.json b/package.json
index 7d43fca1..f8afeeb1 100644
--- a/package.json
+++ b/package.json
@@ -33,7 +33,9 @@
"eslint-plugin-jsx-a11y": "^6.7.1",
"eslint-traverse": "^1.0.0",
"lodash": "^4.17.21",
- "styled-system": "^5.1.5"
+ "styled-system": "^5.1.5",
+ "@typescript-eslint/utils": "7.16.0",
+ "typescript": "^5.5.3"
},
"devDependencies": {
"@changesets/changelog-github": "^0.5.0",
@@ -44,7 +46,9 @@
"eslint-plugin-prettier": "^5.0.1",
"jest": "^29.7.0",
"markdownlint-cli2": "^0.13.0",
- "markdownlint-cli2-formatter-pretty": "^0.0.6"
+ "markdownlint-cli2-formatter-pretty": "^0.0.6",
+ "@typescript-eslint/rule-tester": "7.16.0",
+ "@types/jest": "^29.5.12"
},
"prettier": "@github/prettier-config"
}
diff --git a/src/configs/recommended.js b/src/configs/recommended.js
index 916be1fa..b7cc9481 100644
--- a/src/configs/recommended.js
+++ b/src/configs/recommended.js
@@ -18,6 +18,7 @@ module.exports = {
'primer-react/no-deprecated-props': 'warn',
'primer-react/a11y-remove-disable-tooltip': 'error',
'primer-react/a11y-use-next-tooltip': 'error',
+ 'primer-react/no-unnecessary-components': 'error',
},
settings: {
github: {
diff --git a/src/index.js b/src/index.js
index d9ce57df..5e4a328f 100644
--- a/src/index.js
+++ b/src/index.js
@@ -11,6 +11,7 @@ module.exports = {
'a11y-remove-disable-tooltip': require('./rules/a11y-remove-disable-tooltip'),
'a11y-use-next-tooltip': require('./rules/a11y-use-next-tooltip'),
'use-deprecated-from-deprecated': require('./rules/use-deprecated-from-deprecated'),
+ 'primer-react/no-unnecessary-components': require('./rules/no-unnecessary-components'),
},
configs: {
recommended: require('./configs/recommended'),
diff --git a/src/rules/__tests__/fixtures/File.tsx b/src/rules/__tests__/fixtures/File.tsx
new file mode 100644
index 00000000..275b3a6b
--- /dev/null
+++ b/src/rules/__tests__/fixtures/File.tsx
@@ -0,0 +1 @@
+// https://typescript-eslint.io/packages/rule-tester/#type-aware-testing
diff --git a/src/rules/__tests__/fixtures/file.ts b/src/rules/__tests__/fixtures/file.ts
new file mode 100644
index 00000000..275b3a6b
--- /dev/null
+++ b/src/rules/__tests__/fixtures/file.ts
@@ -0,0 +1 @@
+// https://typescript-eslint.io/packages/rule-tester/#type-aware-testing
diff --git a/src/rules/__tests__/fixtures/tsconfig.json b/src/rules/__tests__/fixtures/tsconfig.json
new file mode 100644
index 00000000..d5991f05
--- /dev/null
+++ b/src/rules/__tests__/fixtures/tsconfig.json
@@ -0,0 +1,7 @@
+// https://typescript-eslint.io/packages/rule-tester/#type-aware-testing
+{
+ "compilerOptions": {
+ "strict": true
+ },
+ "include": ["file.ts", "File.tsx"]
+}
diff --git a/src/rules/__tests__/no-unnecessary-components.test.js b/src/rules/__tests__/no-unnecessary-components.test.js
new file mode 100644
index 00000000..67e327ff
--- /dev/null
+++ b/src/rules/__tests__/no-unnecessary-components.test.js
@@ -0,0 +1,143 @@
+// @ts-check
+
+const {RuleTester} = require('@typescript-eslint/rule-tester')
+
+const path = require('node:path')
+const rule = require('../no-unnecessary-components')
+const {components} = require('../no-unnecessary-components')
+
+const prcImport = 'import React from "react"; import {Box, Text} from "@primer/react";'
+const brandImport = 'import React from "react"; import {Box, Text} from "@primer/brand";'
+
+/** @param {string} content */
+const jsx = content => `export const Component = () => <>${content}>`
+
+const sxObjectDeclaration = `const props = {sx: {color: "red"}};`
+const asObjectDeclaration = `const props = {as: "table"};`
+const stringRecordDeclaration = `const props: Record = {};`
+const testIdObjectDeclaration = `const props = {'data-testid': 'xyz'};`
+const componentDeclaration = `const OtherComponent = ({children}: {children: React.ReactNode}) => <>{children}>;`
+const asConstDeclaration = `const as = "p";`
+
+const ruleTester = new RuleTester({
+ parser: '@typescript-eslint/parser',
+ parserOptions: {
+ tsconfigRootDir: path.resolve(__dirname, 'fixtures'),
+ project: path.resolve(__dirname, 'fixtures', 'tsconfig.json'),
+ },
+ defaultFilenames: {
+ ts: 'file.ts',
+ tsx: 'File.tsx',
+ },
+})
+
+jest.retryTimes(0, {logErrorsBeforeRetry: true})
+
+const filename = 'File.tsx'
+
+ruleTester.run('unnecessary-components', rule, {
+ valid: [
+ {name: 'Unrelated JSX', code: jsx('Hello World'), filename},
+ ...Object.keys(components).flatMap(component => [
+ {
+ name: `Non-PRC ${component}`,
+ code: `${brandImport}${jsx(`<${component}>Hello World${component}>`)}`,
+ filename,
+ },
+ {
+ name: `${component} with sx prop`,
+ code: `${prcImport}${jsx(`<${component} sx={{color: "red"}}>Hello World${component}>`)}`,
+ filename,
+ },
+ {
+ name: `${component} with any styled-system prop`,
+ code: `${prcImport}${jsx(`<${component} flex="row">Hello World${component}>`)}`,
+ filename,
+ },
+ {
+ name: `${component} with spread sx prop`,
+ code: `${prcImport}${sxObjectDeclaration}${jsx(`<${component} {...props}>Hello World${component}>`)}`,
+ filename,
+ },
+ {
+ name: `${component} with string index spread props`,
+ code: `${prcImport}${stringRecordDeclaration}${jsx(`<${component} {...props}>Hello World${component}>`)}`,
+ filename,
+ },
+ ]),
+ ],
+ invalid: Object.entries(components).flatMap(([component, {messageId, replacement}]) => [
+ {
+ name: `${component} without any styled-system props`,
+ code: `${prcImport}${jsx(`<${component}>Hello World${component}>`)}`,
+ output: `${prcImport}${jsx(`<${replacement}>Hello World${replacement}>`)}`,
+ errors: [{messageId}],
+ filename,
+ },
+ {
+ name: `Self-closing ${component} without any styled-system props`,
+ code: `${prcImport}${jsx(`<${component} />`)}`,
+ output: `${prcImport}${jsx(`<${replacement} />`)}`,
+ errors: [{messageId}],
+ filename,
+ },
+ {
+ name: `${component} with spread props without sx`,
+ code: `${prcImport}${testIdObjectDeclaration}${jsx(`<${component} {...props}>Hello World${component}>`)}`,
+ output: `${prcImport}${testIdObjectDeclaration}${jsx(`<${replacement} {...props}>Hello World${replacement}>`)}`,
+ errors: [{messageId}],
+ filename,
+ },
+ {
+ name: `${component} with string element 'as' prop`,
+ code: `${prcImport}${jsx(`<${component} as="code">Hello world${component}>`)}`,
+ // There is extra whitespace here we don't worry about since formatters would get rid of it
+ output: `${prcImport}${jsx(`Hello world
`)}`,
+ errors: [{messageId}],
+ filename,
+ },
+ {
+ name: `${component} with single-character 'as' prop`,
+ code: `${prcImport}${jsx(`<${component} as="p">Hello world${component}>`)}`,
+ output: `${prcImport}${jsx(`Hello world
`)}`,
+ errors: [{messageId}],
+ filename,
+ },
+ {
+ name: `${component} with string element 'as' prop surrounded by unnecessary braces`,
+ code: `${prcImport}${jsx(`<${component} as={"code"}>Hello world${component}>`)}`,
+ output: `${prcImport}${jsx(`Hello world
`)}`,
+ errors: [{messageId}],
+ filename,
+ },
+ {
+ name: `${component} with component reference 'as' prop`,
+ code: `${prcImport}${componentDeclaration}${jsx(`<${component} as={OtherComponent}>Hello world${component}>`)}`,
+ output: `${prcImport}${componentDeclaration}${jsx(`Hello world`)}`,
+ errors: [{messageId}],
+ filename,
+ },
+ {
+ name: `${component} with spread 'as' prop`,
+ code: `${prcImport}${asObjectDeclaration}${jsx(`<${component} {...props}>Hello world${component}>`)}`,
+ output: null,
+ errors: [{messageId}],
+ filename,
+ },
+ {
+ name: `${component} with unusable lowercase reference 'as' prop`,
+ code: `${prcImport}${asConstDeclaration}${jsx(`<${component} as={as}>Hello world${component}>`)}`,
+ output: null,
+ errors: [{messageId}],
+ filename,
+ },
+ {
+ name: `Non-PRC ${component} when \`skipImportCheck\` is enabled`,
+ code: `${brandImport}${jsx(`<${component}>Hello World${component}>`)}`,
+ output: `${brandImport}${jsx(`<${replacement}>Hello World${replacement}>`)}`,
+ filename,
+ errors: [{messageId}],
+ options: [{skipImportCheck: true}],
+ },
+ ]),
+})
diff --git a/src/rules/no-unnecessary-components.js b/src/rules/no-unnecessary-components.js
new file mode 100644
index 00000000..c5701d6a
--- /dev/null
+++ b/src/rules/no-unnecessary-components.js
@@ -0,0 +1,155 @@
+// @ts-check
+
+const {ESLintUtils} = require('@typescript-eslint/utils')
+const {IndexKind} = require('typescript')
+const {pick: pickStyledSystemProps} = require('@styled-system/props')
+const {isPrimerComponent} = require('../utils/is-primer-component')
+
+/** @typedef {import('@typescript-eslint/types').TSESTree.JSXAttribute} JSXAttribute */
+
+const components = {
+ Box: {
+ replacement: 'div',
+ messageId: 'unecessaryBox',
+ message: 'Prefer plain HTML elements over `Box` when not using `sx` for styling.',
+ },
+ Text: {
+ replacement: 'span',
+ messageId: 'unecessarySpan',
+ message: 'Prefer plain HTML elements over `Text` when not using `sx` for styling.',
+ },
+}
+
+const elementNameRegex = /^[a-z]\w*$/
+const componentNameRegex = /^[A-Z][\w._]*$/
+
+/** @param {string} propName */
+const isStyledSystemProp = propName => propName in pickStyledSystemProps({[propName]: propName})
+
+const rule = ESLintUtils.RuleCreator.withoutDocs({
+ meta: {
+ docs: {
+ description:
+ '`Box` and `Text` should only be used to provide access to the `sx` styling system and have a performance cost. If `sx` props are not being used, prefer `div` and `span` instead.',
+ },
+ messages: {
+ [components.Box.messageId]: components.Box.message,
+ [components.Text.messageId]: components.Text.message,
+ },
+ type: 'problem',
+ schema: [
+ {
+ type: 'object',
+ properties: {
+ skipImportCheck: {
+ type: 'boolean',
+ },
+ },
+ additionalProperties: false,
+ },
+ ],
+ fixable: 'code',
+ },
+ defaultOptions: [{skipImportCheck: false}],
+ create(context) {
+ return {
+ JSXElement({openingElement, closingElement}) {
+ const {name, attributes} = openingElement
+
+ // Ensure this is one of the components we are looking for. Note this doesn't account for import aliases; this
+ // is intentional to avoid having to do the scope tree traversal for every component of every name, which would
+ // be needlessly expensive. We just ignore aliased imports.
+ if (name.type !== 'JSXIdentifier' || !(name.name in components)) return
+ const componentConfig = components[/** @type {keyof typeof components} */ (name.name)]
+
+ // Only continue if the variable declaration is an import from @primer/react. Otherwise it could, for example,
+ // be an import from @primer/brand, which would be valid without sx.
+ const skipImportCheck = context.options[0]?.skipImportCheck
+ const isPrimer = skipImportCheck || isPrimerComponent(name, context.sourceCode.getScope(openingElement))
+ if (!isPrimer) return
+
+ // Validate the attributes and ensure an `sx` prop is present or spreaded in
+ /** @type {typeof attributes[number] | undefined | null} */
+ let asProp = undefined
+ for (const attribute of attributes) {
+ // If there is a spread type, check if the type of the spreaded value has an `sx` property
+ if (attribute.type === 'JSXSpreadAttribute') {
+ const services = ESLintUtils.getParserServices(context)
+ const typeChecker = services.program.getTypeChecker()
+
+ const spreadType = services.getTypeAtLocation(attribute.argument)
+ if (typeChecker.getPropertyOfType(spreadType, 'sx') !== undefined) return
+
+ // Check if the spread type has a string index signature - this could hide an `sx` property
+ if (typeChecker.getIndexTypeOfType(spreadType, IndexKind.String) !== undefined) return
+
+ // If there is an `as` inside the spread object, we can't autofix reliably
+ if (typeChecker.getPropertyOfType(spreadType, 'as') !== undefined) asProp = null
+
+ continue
+ }
+
+ // Has sx prop, so should keep using this component
+ if (
+ attribute.name.type === 'JSXIdentifier' &&
+ (attribute.name.name === 'sx' || isStyledSystemProp(attribute.name.name))
+ )
+ return
+
+ // If there is an `as` prop we will need to account for that when autofixing
+ if (attribute.name.type === 'JSXIdentifier' && attribute.name.name === 'as') asProp = attribute
+ }
+
+ // Determine a replacement component name accounting for the `as` prop if present
+ /** @type {string | null} */
+ let replacement = componentConfig.replacement
+ if (asProp === null) {
+ // {...{as: 'something-unusable'}}
+ replacement = null
+ } else if (asProp?.type === 'JSXAttribute') {
+ // as={ComponentReference}
+ if (asProp.value?.type === 'JSXExpressionContainer' && asProp.value.expression.type === 'Identifier') {
+ // can't just use expression.name here because we want the whole expression if it's A.B
+ const expressionStr = context.sourceCode.getText(asProp.value.expression)
+ replacement = componentNameRegex.test(expressionStr) ? expressionStr : null
+ }
+ // as={'tagName'} (surprisingly common, we really should enable `react/jsx-curly-brace-presence`)
+ else if (
+ asProp.value?.type === 'JSXExpressionContainer' &&
+ asProp.value.expression.type === 'Literal' &&
+ typeof asProp.value.expression.value === 'string' &&
+ elementNameRegex.test(asProp.value.expression.value)
+ ) {
+ replacement = asProp.value.expression.value
+ }
+ // as="tagName"
+ else if (
+ asProp.value?.type === 'Literal' &&
+ typeof asProp.value.value === 'string' &&
+ elementNameRegex.test(asProp.value.value)
+ ) {
+ replacement = asProp.value.value
+ }
+ // too complex to autofix
+ else {
+ replacement = null
+ }
+ }
+
+ context.report({
+ node: name,
+ messageId: componentConfig.messageId,
+ fix: replacement
+ ? function* (fixer) {
+ yield fixer.replaceText(name, replacement)
+ if (closingElement) yield fixer.replaceText(closingElement.name, replacement)
+ if (asProp) yield fixer.remove(asProp)
+ }
+ : undefined,
+ })
+ },
+ }
+ },
+})
+
+module.exports = {...rule, components}
diff --git a/src/utils/is-primer-component.js b/src/utils/is-primer-component.js
index b5f1c543..10b1ed6e 100644
--- a/src/utils/is-primer-component.js
+++ b/src/utils/is-primer-component.js
@@ -1,5 +1,6 @@
const {isImportedFrom} = require('./is-imported-from')
+/** @returns {boolean} */
function isPrimerComponent(name, scope) {
let identifier