diff --git a/.changeset/gorgeous-games-confess.md b/.changeset/gorgeous-games-confess.md
new file mode 100644
index 00000000..f9023bbb
--- /dev/null
+++ b/.changeset/gorgeous-games-confess.md
@@ -0,0 +1,5 @@
+---
+'eslint-plugin-primer-react': minor
+---
+
+Added a no-system-props rule
diff --git a/docs/rules/no-system-props.md b/docs/rules/no-system-props.md
new file mode 100644
index 00000000..75e361db
--- /dev/null
+++ b/docs/rules/no-system-props.md
@@ -0,0 +1,40 @@
+# Disallow use of styled-system props (no-system-colors)
+
+🔧 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.
+
+[Styled-system](https://styled-system.com/table) props are deprecated in Primer components (excluding utility components).
+
+## Rule details
+
+This rule disallows the use of any styled-system prop on Primer components, as the `sx` prop is now the prefered way to apply additional styling.
+
+\*The two non-deprecated utility components (`Box` and `Text`) are allowed to use system props.
+
+👎 Examples of **incorrect** code for this rule:
+
+```jsx
+/* eslint primer-react/no-system-props: "error" */
+import {Button} from '@primer/components'
+
+
+
+```
+
+👍 Examples of **correct** code for this rule:
+
+```jsx
+/* eslint primer-react/no-system-props: "error" */
+import {Box, Button, ProgressBar} from '@primer/components'
+import {Avatar} from 'some-other-library'
+// Non-system props are allowed
+
+// If you need to override styles, use the `sx` prop instead of system props
+
+// Some component prop names overlap with styled-system prop names.
+// These props are still allowed
+
+// Utility components like Box and Text still accept system props
+
+// System props passed to non-Primer components are allowed
+
+```
diff --git a/package-lock.json b/package-lock.json
index c7e5ecd3..20b12d66 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -1,6 +1,6 @@
{
"name": "eslint-plugin-primer-react",
- "version": "0.4.1",
+ "version": "0.5.0",
"lockfileVersion": 1,
"requires": true,
"dependencies": {
@@ -1309,6 +1309,116 @@
"@sinonjs/commons": "^1.7.0"
}
},
+ "@styled-system/background": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@styled-system/background/-/background-5.1.2.tgz",
+ "integrity": "sha512-jtwH2C/U6ssuGSvwTN3ri/IyjdHb8W9X/g8Y0JLcrH02G+BW3OS8kZdHphF1/YyRklnrKrBT2ngwGUK6aqqV3A==",
+ "requires": {
+ "@styled-system/core": "^5.1.2"
+ }
+ },
+ "@styled-system/border": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmjs.org/@styled-system/border/-/border-5.1.5.tgz",
+ "integrity": "sha512-JvddhNrnhGigtzWRCVuAHepniyVi6hBlimxWDVAdcTuk7aRn9BYJUwfHslURtwYFsF5FoEs8Zmr1oZq2M1AP0A==",
+ "requires": {
+ "@styled-system/core": "^5.1.2"
+ }
+ },
+ "@styled-system/color": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@styled-system/color/-/color-5.1.2.tgz",
+ "integrity": "sha512-1kCkeKDZkt4GYkuFNKc7vJQMcOmTl3bJY3YBUs7fCNM6mMYJeT1pViQ2LwBSBJytj3AB0o4IdLBoepgSgGl5MA==",
+ "requires": {
+ "@styled-system/core": "^5.1.2"
+ }
+ },
+ "@styled-system/core": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@styled-system/core/-/core-5.1.2.tgz",
+ "integrity": "sha512-XclBDdNIy7OPOsN4HBsawG2eiWfCcuFt6gxKn1x4QfMIgeO6TOlA2pZZ5GWZtIhCUqEPTgIBta6JXsGyCkLBYw==",
+ "requires": {
+ "object-assign": "^4.1.1"
+ }
+ },
+ "@styled-system/css": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmjs.org/@styled-system/css/-/css-5.1.5.tgz",
+ "integrity": "sha512-XkORZdS5kypzcBotAMPBoeckDs9aSZVkvrAlq5K3xP8IMAUek+x2O4NtwoSgkYkWWzVBu6DGdFZLR790QWGG+A=="
+ },
+ "@styled-system/flexbox": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@styled-system/flexbox/-/flexbox-5.1.2.tgz",
+ "integrity": "sha512-6hHV52+eUk654Y1J2v77B8iLeBNtc+SA3R4necsu2VVinSD7+XY5PCCEzBFaWs42dtOEDIa2lMrgL0YBC01mDQ==",
+ "requires": {
+ "@styled-system/core": "^5.1.2"
+ }
+ },
+ "@styled-system/grid": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@styled-system/grid/-/grid-5.1.2.tgz",
+ "integrity": "sha512-K3YiV1KyHHzgdNuNlaw8oW2ktMuGga99o1e/NAfTEi5Zsa7JXxzwEnVSDSBdJC+z6R8WYTCYRQC6bkVFcvdTeg==",
+ "requires": {
+ "@styled-system/core": "^5.1.2"
+ }
+ },
+ "@styled-system/layout": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@styled-system/layout/-/layout-5.1.2.tgz",
+ "integrity": "sha512-wUhkMBqSeacPFhoE9S6UF3fsMEKFv91gF4AdDWp0Aym1yeMPpqz9l9qS/6vjSsDPF7zOb5cOKC3tcKKOMuDCPw==",
+ "requires": {
+ "@styled-system/core": "^5.1.2"
+ }
+ },
+ "@styled-system/position": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@styled-system/position/-/position-5.1.2.tgz",
+ "integrity": "sha512-60IZfMXEOOZe3l1mCu6sj/2NAyUmES2kR9Kzp7s2D3P4qKsZWxD1Se1+wJvevb+1TP+ZMkGPEYYXRyU8M1aF5A==",
+ "requires": {
+ "@styled-system/core": "^5.1.2"
+ }
+ },
+ "@styled-system/props": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmjs.org/@styled-system/props/-/props-5.1.5.tgz",
+ "integrity": "sha512-FXhbzq2KueZpGaHxaDm8dowIEWqIMcgsKs6tBl6Y6S0njG9vC8dBMI6WSLDnzMoSqIX3nSKHmOmpzpoihdDewg==",
+ "requires": {
+ "styled-system": "^5.1.5"
+ }
+ },
+ "@styled-system/shadow": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@styled-system/shadow/-/shadow-5.1.2.tgz",
+ "integrity": "sha512-wqniqYb7XuZM7K7C0d1Euxc4eGtqEe/lvM0WjuAFsQVImiq6KGT7s7is+0bNI8O4Dwg27jyu4Lfqo/oIQXNzAg==",
+ "requires": {
+ "@styled-system/core": "^5.1.2"
+ }
+ },
+ "@styled-system/space": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@styled-system/space/-/space-5.1.2.tgz",
+ "integrity": "sha512-+zzYpR8uvfhcAbaPXhH8QgDAV//flxqxSjHiS9cDFQQUSznXMQmxJegbhcdEF7/eNnJgHeIXv1jmny78kipgBA==",
+ "requires": {
+ "@styled-system/core": "^5.1.2"
+ }
+ },
+ "@styled-system/typography": {
+ "version": "5.1.2",
+ "resolved": "https://registry.npmjs.org/@styled-system/typography/-/typography-5.1.2.tgz",
+ "integrity": "sha512-BxbVUnN8N7hJ4aaPOd7wEsudeT7CxarR+2hns8XCX1zp0DFfbWw4xYa/olA0oQaqx7F1hzDg+eRaGzAJbF+jOg==",
+ "requires": {
+ "@styled-system/core": "^5.1.2"
+ }
+ },
+ "@styled-system/variant": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmjs.org/@styled-system/variant/-/variant-5.1.5.tgz",
+ "integrity": "sha512-Yn8hXAFoWIro8+Q5J8YJd/mP85Teiut3fsGVR9CAxwgNfIAiqlYxsk5iHU7VHJks/0KjL4ATSjmbtCDC/4l1qw==",
+ "requires": {
+ "@styled-system/core": "^5.1.2",
+ "@styled-system/css": "^5.1.5"
+ }
+ },
"@tootallnate/once": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@tootallnate/once/-/once-1.1.2.tgz",
@@ -3874,8 +3984,7 @@
"lodash": {
"version": "4.17.21",
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
- "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
- "dev": true
+ "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
},
"lodash.clonedeep": {
"version": "4.5.0",
@@ -4129,6 +4238,11 @@
"integrity": "sha512-h2AatdwYH+JHiZpv7pt/gSX1XoRGb7L/qSIeuqA6GwYoF9w1vP1cw42TO0aI2pNyshRK5893hNSl+1//vHK7hQ==",
"dev": true
},
+ "object-assign": {
+ "version": "4.1.1",
+ "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
+ "integrity": "sha1-IQmtx5ZYh8/AXLvUQsrIv7s2CGM="
+ },
"once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -4949,6 +5063,26 @@
"integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==",
"dev": true
},
+ "styled-system": {
+ "version": "5.1.5",
+ "resolved": "https://registry.npmjs.org/styled-system/-/styled-system-5.1.5.tgz",
+ "integrity": "sha512-7VoD0o2R3RKzOzPK0jYrVnS8iJdfkKsQJNiLRDjikOpQVqQHns/DXWaPZOH4tIKkhAT7I6wIsy9FWTWh2X3q+A==",
+ "requires": {
+ "@styled-system/background": "^5.1.2",
+ "@styled-system/border": "^5.1.5",
+ "@styled-system/color": "^5.1.2",
+ "@styled-system/core": "^5.1.2",
+ "@styled-system/flexbox": "^5.1.2",
+ "@styled-system/grid": "^5.1.2",
+ "@styled-system/layout": "^5.1.2",
+ "@styled-system/position": "^5.1.2",
+ "@styled-system/shadow": "^5.1.2",
+ "@styled-system/space": "^5.1.2",
+ "@styled-system/typography": "^5.1.2",
+ "@styled-system/variant": "^5.1.5",
+ "object-assign": "^4.1.1"
+ }
+ },
"supports-color": {
"version": "7.2.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz",
diff --git a/package.json b/package.json
index bd1ce8aa..3ab7b0ef 100644
--- a/package.json
+++ b/package.json
@@ -34,6 +34,9 @@
},
"prettier": "@github/prettier-config",
"dependencies": {
- "eslint-traverse": "^1.0.0"
+ "@styled-system/props": "^5.1.5",
+ "eslint-traverse": "^1.0.0",
+ "lodash": "^4.17.21",
+ "styled-system": "^5.1.5"
}
}
diff --git a/src/configs/recommended.js b/src/configs/recommended.js
index 81cf6e25..312652f1 100644
--- a/src/configs/recommended.js
+++ b/src/configs/recommended.js
@@ -7,6 +7,7 @@ module.exports = {
},
plugins: ['primer-react'],
rules: {
- 'primer-react/no-deprecated-colors': 'warn'
+ 'primer-react/no-deprecated-colors': 'warn',
+ 'primer-react/no-system-props': 'warn'
}
}
diff --git a/src/index.js b/src/index.js
index 23ded67a..b3714491 100644
--- a/src/index.js
+++ b/src/index.js
@@ -1,6 +1,7 @@
module.exports = {
rules: {
- 'no-deprecated-colors': require('./rules/no-deprecated-colors')
+ 'no-deprecated-colors': require('./rules/no-deprecated-colors'),
+ 'no-system-props': require('./rules/no-system-props')
},
configs: {
recommended: require('./configs/recommended')
diff --git a/src/rules/__tests__/no-system-props.test.js b/src/rules/__tests__/no-system-props.test.js
new file mode 100644
index 00000000..50a3bdc7
--- /dev/null
+++ b/src/rules/__tests__/no-system-props.test.js
@@ -0,0 +1,125 @@
+const rule = require('../no-system-props')
+const {RuleTester} = require('eslint')
+
+const ruleTester = new RuleTester({
+ parserOptions: {
+ ecmaVersion: 'latest',
+ sourceType: 'module',
+ ecmaFeatures: {
+ jsx: true
+ }
+ }
+})
+
+ruleTester.run('no-system-props', rule, {
+ valid: [
+ `import {Button} from '@primer/components'; `,
+ `import {Button} from 'coles-cool-design-system'; `,
+ `import {Button} from '@primer/components'; `,
+ `import {Box} from '@primer/components'; `,
+ `import {ProgressBar} from '@primer/components'; `,
+ `import {Button} from '@primer/components'; `
+ ],
+ invalid: [
+ {
+ code: `import {Button} from '@primer/components'; `,
+ output: `import {Button} from '@primer/components'; `,
+ errors: [
+ {
+ messageId: 'noSystemProps',
+ data: {propNames: 'width', componentName: 'Button'}
+ }
+ ]
+ },
+ {
+ code: `import {Button} from '@primer/components'; `,
+ output: `import {Button} from '@primer/components'; `,
+ errors: [
+ {
+ messageId: 'noSystemProps',
+ data: {propNames: 'width', componentName: 'Button'}
+ }
+ ]
+ },
+ {
+ code: `import {Button} from '@primer/components'; `,
+ output: `import {Button} from '@primer/components'; `,
+ errors: [
+ {
+ messageId: 'noSystemProps',
+ data: {propNames: 'width', componentName: 'Button'}
+ }
+ ]
+ },
+ {
+ code: `import {Button} from '@primer/components'; `,
+ output: `import {Button} from '@primer/components'; `,
+ errors: [
+ {
+ messageId: 'noSystemProps',
+ data: {propNames: 'width', componentName: 'Button'}
+ }
+ ]
+ },
+ {
+ code: `import {Button} from '@primer/components'; `,
+ output: `import {Button} from '@primer/components'; `,
+ errors: [
+ {
+ messageId: 'noSystemProps',
+ data: {propNames: 'width, height', componentName: 'Button'}
+ }
+ ]
+ },
+ {
+ code: `import {Button} from '@primer/components'; `,
+ output: `import {Button} from '@primer/components'; `,
+ errors: [
+ {
+ messageId: 'noSystemProps',
+ data: {propNames: 'width', componentName: 'Button'}
+ }
+ ]
+ },
+ {
+ code: `import {Button} from '@primer/components'; `,
+ output: `import {Button} from '@primer/components'; `,
+ errors: [
+ {
+ messageId: 'noSystemProps',
+ data: {propNames: 'width', componentName: 'Button'}
+ }
+ ]
+ },
+ {
+ code: `import {Button} from '@primer/components'; `,
+ output: `import {Button} from '@primer/components'; `,
+ errors: [
+ {
+ messageId: 'noSystemProps',
+ data: {propNames: 'width', componentName: 'Button'}
+ }
+ ]
+ },
+ {
+ code: `import {Button} from '@primer/components'; `,
+ output: `import {Button} from '@primer/components'; `,
+ errors: [
+ {
+ messageId: 'noSystemProps',
+ data: {propNames: 'width', componentName: 'Button'}
+ }
+ ]
+ },
+ {
+ code: `import {Label} from '@primer/components'; `,
+ output: `import {Label} from '@primer/components'; `,
+ errors: [
+ {
+ messageId: 'noSystemProps',
+ data: {propNames: 'width', componentName: 'Label'}
+ }
+ ]
+ }
+ ]
+})
diff --git a/src/rules/no-deprecated-colors.js b/src/rules/no-deprecated-colors.js
index c4509ced..a1791dae 100644
--- a/src/rules/no-deprecated-colors.js
+++ b/src/rules/no-deprecated-colors.js
@@ -1,5 +1,7 @@
const deprecations = require('@primer/primitives/dist/deprecations/colors')
const traverse = require('eslint-traverse')
+const {isImportedFrom} = require('../utils/isImportedFrom')
+const {isPrimerComponent} = require('../utils/isPrimerComponent')
const styledSystemColorProps = ['color', 'bg', 'backgroundColor', 'borderColor', 'textShadow', 'boxShadow']
@@ -125,37 +127,6 @@ module.exports = {
}
}
-/**
- * Get the variable declaration for the given identifier
- */
-function getVariableDeclaration(scope, identifier) {
- if (scope === null) {
- return null
- }
-
- for (const variable of scope.variables) {
- if (variable.name === identifier.name) {
- return variable.defs[0]
- }
- }
-
- return getVariableDeclaration(scope.upper, identifier)
-}
-
-/**
- * Check if the given identifier is imported from the given module
- */
-function isImportedFrom(moduleRegex, identifier, scope) {
- const definition = getVariableDeclaration(scope, identifier)
-
- // Return true if the variable was imported from the given module
- return definition && definition.type == 'ImportBinding' && moduleRegex.test(definition.parent.source.value)
-}
-
-function isPrimerComponent(identifier, scope) {
- return isImportedFrom(/^@primer\/components/, identifier, scope)
-}
-
function isThemeGet(identifier, scope, skipImportCheck = false) {
if (!skipImportCheck) {
return isImportedFrom(/^@primer\/components/, identifier, scope) && identifier.name === 'themeGet'
diff --git a/src/rules/no-system-props.js b/src/rules/no-system-props.js
new file mode 100644
index 00000000..5db05cba
--- /dev/null
+++ b/src/rules/no-system-props.js
@@ -0,0 +1,139 @@
+const {isPrimerComponent} = require('../utils/is-primer-component')
+const {pick} = require('@styled-system/props')
+const {some, last} = require('lodash')
+
+// Components for which we allow all styled system props
+const excludedComponents = new Set([
+ 'Box',
+ 'Text',
+ 'BaseStyles' // BaseStyles will be deprecated eventually
+])
+
+// Components for which we allow a set of prop names
+const excludedComponentProps = new Map([
+ ['AnchoredOverlay', new Set(['width', 'height'])],
+ ['Avatar', new Set(['size'])],
+ ['Dialog', new Set(['width', 'height'])],
+ ['Flash', new Set(['variant'])],
+ ['Label', new Set(['variant'])],
+ ['ProgressBar', new Set(['bg'])],
+ ['Spinner', new Set(['size'])],
+ ['StyledOcticon', new Set(['size'])]
+])
+
+module.exports = {
+ meta: {
+ type: 'suggestion',
+ fixable: 'code',
+ schema: [],
+ messages: {
+ noSystemProps: 'Styled-system props are deprecated ({{ componentName }} called with props: {{ propNames }})'
+ }
+ },
+ create(context) {
+ return {
+ JSXOpeningElement(jsxNode) {
+ if (!isPrimerComponent(jsxNode.name, context.getScope(jsxNode))) return
+ if (excludedComponents.has(jsxNode.name.name)) return
+
+ // Create an object mapping from prop name to the AST node for that attribute
+ const propsByNameObject = jsxNode.attributes.reduce((object, attribute) => {
+ // We don't do anything about spreads for now — only named attributes
+ if (attribute.type === 'JSXAttribute') {
+ object[attribute.name.name] = attribute
+ }
+
+ return object
+ }, {})
+
+ // Create an array of system prop attribute nodes
+ let systemProps = Object.values(pick(propsByNameObject))
+
+ // Filter out our exceptional props
+ systemProps = systemProps.filter(prop => {
+ const excludedProps = excludedComponentProps.get(jsxNode.name.name)
+ if (!excludedProps) {
+ return true
+ }
+ return !excludedProps.has(prop.name.name)
+ })
+
+ if (systemProps.length !== 0) {
+ context.report({
+ node: jsxNode,
+ messageId: 'noSystemProps',
+ data: {
+ componentName: jsxNode.name.name,
+ propNames: systemProps.map(a => a.name.name).join(', ')
+ },
+ fix(fixer) {
+ const existingSxProp = jsxNode.attributes.find(
+ attribute => attribute.type === 'JSXAttribute' && attribute.name.name === 'sx'
+ )
+ const systemPropstylesMap = stylesMapFromPropNodes(systemProps, context)
+ if (existingSxProp && existingSxProp.value.expression.type !== 'ObjectExpression') {
+ return
+ }
+
+ const stylesToAdd = existingSxProp
+ ? excludeSxEntriesFromStyleMap(systemPropstylesMap, existingSxProp)
+ : systemPropstylesMap
+
+ return [
+ // Remove the bad props
+ ...systemProps.map(node => fixer.remove(node)),
+ ...(stylesToAdd.size > 0
+ ? [
+ existingSxProp
+ ? // Update an existing sx prop
+ fixer.insertTextAfter(
+ last(existingSxProp.value.expression.properties),
+ `, ${objectEntriesStringFromStylesMap(stylesToAdd)}`
+ )
+ : // Insert new sx prop
+ fixer.insertTextAfter(last(jsxNode.attributes), sxPropTextFromStylesMap(systemPropstylesMap))
+ ]
+ : [])
+ ]
+ }
+ })
+ }
+ }
+ }
+ }
+}
+
+const sxPropTextFromStylesMap = styles => {
+ return ` sx={{${objectEntriesStringFromStylesMap(styles)}}}`
+}
+
+const objectEntriesStringFromStylesMap = styles => {
+ return [...styles].map(([name, value]) => `${name}: ${value}`).join(', ')
+}
+
+// Given an array of styled prop attributes, return a mapping from attribute to expression
+const stylesMapFromPropNodes = (systemProps, context) => {
+ return new Map(
+ systemProps.map(a => [
+ a.name.name,
+ a.value === null ? 'true' : a.value.raw || context.getSourceCode().getText(a.value.expression)
+ ])
+ )
+}
+
+// Given a style map and an existing sx prop, return a style map containing
+// only the entries that aren't already overridden by an sx object entry
+const excludeSxEntriesFromStyleMap = (stylesMap, sxProp) => {
+ if (
+ !sxProp.value ||
+ sxProp.value.type !== 'JSXExpressionContainer' ||
+ sxProp.value.expression.type != 'ObjectExpression'
+ ) {
+ return stylesMap
+ }
+ return new Map(
+ [...stylesMap].filter(([key, _value]) => {
+ return !some(sxProp.value.expression.properties, p => p.type === 'Property' && p.key.name === key)
+ })
+ )
+}
diff --git a/src/utils/get-variable-declaration.js b/src/utils/get-variable-declaration.js
new file mode 100644
index 00000000..1ad057dd
--- /dev/null
+++ b/src/utils/get-variable-declaration.js
@@ -0,0 +1,17 @@
+/**
+ * Get the variable declaration for the given identifier
+ */
+function getVariableDeclaration(scope, identifier) {
+ if (scope === null) {
+ return null
+ }
+
+ for (const variable of scope.variables) {
+ if (variable.name === identifier.name) {
+ return variable.defs[0]
+ }
+ }
+
+ return getVariableDeclaration(scope.upper, identifier)
+}
+exports.getVariableDeclaration = getVariableDeclaration
diff --git a/src/utils/is-imported-from.js b/src/utils/is-imported-from.js
new file mode 100644
index 00000000..ff5e08d7
--- /dev/null
+++ b/src/utils/is-imported-from.js
@@ -0,0 +1,12 @@
+const {getVariableDeclaration} = require('./get-variable-declaration')
+
+/**
+ * Check if the given identifier is imported from the given module
+ */
+function isImportedFrom(moduleRegex, identifier, scope) {
+ const definition = getVariableDeclaration(scope, identifier)
+
+ // Return true if the variable was imported from the given module
+ return definition && definition.type == 'ImportBinding' && moduleRegex.test(definition.parent.source.value)
+}
+exports.isImportedFrom = isImportedFrom
diff --git a/src/utils/is-primer-component.js b/src/utils/is-primer-component.js
new file mode 100644
index 00000000..bb333c06
--- /dev/null
+++ b/src/utils/is-primer-component.js
@@ -0,0 +1,6 @@
+const {isImportedFrom} = require('./is-imported-from')
+
+function isPrimerComponent(identifier, scope) {
+ return isImportedFrom(/^@primer\/components/, identifier, scope)
+}
+exports.isPrimerComponent = isPrimerComponent