From 280aa96a7a54bb9013b4ca4d0f0d59a23921631d Mon Sep 17 00:00:00 2001 From: Armagan Ersoz Date: Fri, 27 Sep 2024 16:18:47 +1000 Subject: [PATCH] Add rules - wip --- ...xperimental-to-be-stable-from-root.test.js | 50 +++++++ ...import-next-to-be-stable-from-root.test.js | 34 +++++ ...ort-experimental-to-be-stable-from-root.js | 137 ++++++++++++++++++ .../import-next-to-be-stable-from-root.js | 133 +++++++++++++++++ 4 files changed, 354 insertions(+) create mode 100644 src/rules/__tests__/import-experimental-to-be-stable-from-root.test.js create mode 100644 src/rules/__tests__/import-next-to-be-stable-from-root.test.js create mode 100644 src/rules/import-experimental-to-be-stable-from-root.js create mode 100644 src/rules/import-next-to-be-stable-from-root.js diff --git a/src/rules/__tests__/import-experimental-to-be-stable-from-root.test.js b/src/rules/__tests__/import-experimental-to-be-stable-from-root.test.js new file mode 100644 index 00000000..4cbae928 --- /dev/null +++ b/src/rules/__tests__/import-experimental-to-be-stable-from-root.test.js @@ -0,0 +1,50 @@ +'use strict' + +const {RuleTester} = require('eslint') +const rule = require('../import-next-to-be-stable-from-root') + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, +}) + +ruleTester.run('import-next-to-be-stable-from-root', rule, { + valid: [], + invalid: [ + // // Single experimental import + { + code: `import {Dialog} from '@primer/react/experimental'`, + output: `import {Dialog} from '@primer/react'`, + errors: [{messageId: 'importToBeStableFromRoot', line: 1}], + }, + + // Multiple experimental imports + { + code: `import {Dialog, Stack} from '@primer/react/experimental'`, + output: `import {Dialog, Stack} from '@primer/react'`, + errors: [{messageId: 'importToBeStableFromRoot', line: 1}], + }, + + // // Mix stable and non-stable imports from experimental entrypoint + { + code: `import {SelectPanel, Dialog, Stack} from '@primer/react/experimental'`, + output: `import {Dialog, Stack} from '@primer/react', + import {SelectPanel} from '@primer/react/experimental'`, + errors: [{messageId: 'importToBeStableFromRoot', line: 1}], + }, + + // Mix stable and non-stable imports from experimental entrypoint with existing stable + { + code: `import {SelectPanel, Dialog, Stack} from '@primer/react/experimental' +import {Button} from '@primer/react'`, + output: `import {Button, Dialog, Stack} from '@primer/react' + import {SelectPanel} from '@primer/react/experimental'`, + errors: [{messageId: 'importToBeStableFromRoot', line: 1}], + }, + ], +}) diff --git a/src/rules/__tests__/import-next-to-be-stable-from-root.test.js b/src/rules/__tests__/import-next-to-be-stable-from-root.test.js new file mode 100644 index 00000000..cfb08a9a --- /dev/null +++ b/src/rules/__tests__/import-next-to-be-stable-from-root.test.js @@ -0,0 +1,34 @@ +'use strict' + +const {RuleTester} = require('eslint') +const rule = require('../import-next-to-be-stable-from-root') + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 'latest', + sourceType: 'module', + ecmaFeatures: { + jsx: true, + }, + }, +}) + +ruleTester.run('import-next-to-be-stable-from-root', rule, { + valid: [], + invalid: [ + // Single next import + { + code: `import {Tooltip} from '@primer/react/next'`, + output: `import {Tooltip} from '@primer/react'`, + errors: [{messageId: 'importToBeStableFromRoot', line: 1}], + }, + + // // With existing stable entrypoint + { + code: `import {Tooltip} from '@primer/react/next' +import {Button} from '@primer/react'`, + output: `\nimport {Button, Tooltip} from '@primer/react'`, + errors: [{messageId: 'importToBeStableFromRoot', line: 1}], + }, + ], +}) diff --git a/src/rules/import-experimental-to-be-stable-from-root.js b/src/rules/import-experimental-to-be-stable-from-root.js new file mode 100644 index 00000000..0497d252 --- /dev/null +++ b/src/rules/import-experimental-to-be-stable-from-root.js @@ -0,0 +1,137 @@ +'use strict' + +const url = require('../url') + +const components = [ + { + identifier: 'Dialog', + entrypoint: '@primer/react/experimental', + }, + { + identifier: 'DialogProps', + entrypoint: '@primer/react/experimental', + }, + { + identifier: 'DialogButtonProps', + entrypoint: '@primer/react/experimental', + }, + { + identifier: 'Stack', + entrypoint: '@primer/react/experimental', + }, + { + identifier: 'StackProps', + entrypoint: '@primer/react/experimental', + }, + { + identifier: 'StackItemProps', + entrypoint: '@primer/react/experimental', + }, +] + +// Maps entrypoints to a set of component +const entrypoints = new Map() + +for (const component of components) { + if (!entrypoints.has(component.entrypoint)) { + entrypoints.set(component.entrypoint, new Set()) + } + entrypoints.get(component.entrypoint).add(component.identifier) +} + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Use stable components from the `@primer/react` entrypoint', + recommended: true, + url: url(module), + }, + fixable: true, + schema: [], + messages: { + importToBeStableFromRoot: "Import stable components from '@primer/react' entrypoint", + }, + }, + create(context) { + const sourceCode = context.getSourceCode() + + return { + ImportDeclaration(node) { + if (!entrypoints.has(node.source.value)) { + return + } + + const entrypoint = entrypoints.get(node.source.value) + + const stableComponents = node.specifiers.filter(specifier => { + return entrypoint.has(specifier.imported.name) + }) + + if (stableComponents.length === 0) { + return + } + + const stableEntrypoint = node.parent.body.find(node => { + if (node.type !== 'ImportDeclaration') { + return false + } + + return node.source.value === '@primer/react' + }) + + // All imports are from stable + if (stableComponents.length === node.specifiers.length) { + context.report({ + node, + messageId: 'importToBeStableFromRoot', + *fix(fixer) { + if (stableEntrypoint) { + const lastSpecifier = stableEntrypoint.specifiers[stableEntrypoint.specifiers.length - 1] + yield fixer.remove(node) + yield fixer.insertTextAfter( + lastSpecifier, + `, ${node.specifiers.map(specifier => specifier.imported.name).join(', ')}`, + ) + } else { + yield fixer.replaceText(node.source, `'@primer/react'`) + } + }, + }) + } else { + // There is a mix of stable and non-stable imports + context.report({ + node, + messageId: 'importToBeStableFromRoot', + *fix(fixer) { + for (const specifier of stableComponents) { + yield fixer.remove(specifier) + const comma = sourceCode.getTokenAfter(specifier) + if (comma.value === ',') { + yield fixer.remove(comma) + } + } + if (stableEntrypoint) { + const lastSpecifier = stableEntrypoint.specifiers[stableEntrypoint.specifiers.length - 1] + yield fixer.insertTextAfter( + lastSpecifier, + `, ${stableComponents.map(specifier => specifier.imported.name).join(', ')}`, + ) + } else { + yield fixer.insertTextAfter( + node, + `\nimport {${stableComponents + .map(specifier => specifier.imported.name) + .join(', ')}} from '@primer/react'`, + ) + } + }, + }) + } + }, + } + }, +} diff --git a/src/rules/import-next-to-be-stable-from-root.js b/src/rules/import-next-to-be-stable-from-root.js new file mode 100644 index 00000000..18e71061 --- /dev/null +++ b/src/rules/import-next-to-be-stable-from-root.js @@ -0,0 +1,133 @@ +'use strict' + +const url = require('../url') + +const components = [ + { + identifier: 'Tooltip', + entrypoint: '@primer/react/next', + }, + { + identifier: 'TooltipProps', + entrypoint: '@primer/react/next', + }, + { + identifier: 'TooltipDirection', + entrypoint: '@primer/react/next', + }, + { + identifier: 'TriggerPropsType', + entrypoint: '@primer/react/next', + }, + { + identifier: 'TooltipContext', + entrypoint: '@primer/react/next', + }, +] + +// Maps entrypoints to a set of component +const entrypoints = new Map() + +for (const component of components) { + if (!entrypoints.has(component.entrypoint)) { + entrypoints.set(component.entrypoint, new Set()) + } + entrypoints.get(component.entrypoint).add(component.identifier) +} + +/** + * @type {import('eslint').Rule.RuleModule} + */ +module.exports = { + meta: { + type: 'problem', + docs: { + description: 'Use stable components from the `@primer/react` entrypoint', + recommended: true, + url: url(module), + }, + fixable: true, + schema: [], + messages: { + importToBeStableFromRoot: "Import stable components from '@primer/react' entrypoint", + }, + }, + create(context) { + const sourceCode = context.getSourceCode() + + return { + ImportDeclaration(node) { + if (!entrypoints.has(node.source.value)) { + return + } + + const entrypointMapValue = entrypoints.get(node.source.value) + + const componentsToPromote = node.specifiers.filter(specifier => { + return entrypointMapValue.has(specifier.imported.name) + }) + + if (componentsToPromote.length === 0) { + return + } + + const stableEntrypoint = node.parent.body.find(node => { + if (node.type !== 'ImportDeclaration') { + return false + } + + return node.source.value === '@primer/react' + }) + + // All imports are from stable + if (componentsToPromote.length === node.specifiers.length) { + context.report({ + node, + messageId: 'importToBeStableFromRoot', + *fix(fixer) { + if (stableEntrypoint) { + const lastSpecifier = stableEntrypoint.specifiers[stableEntrypoint.specifiers.length - 1] + yield fixer.remove(node) + yield fixer.insertTextAfter( + lastSpecifier, + `, ${node.specifiers.map(specifier => specifier.imported.name).join(', ')}`, + ) + } else { + yield fixer.replaceText(node.source, `'@primer/react'`) + } + }, + }) + } else { + // There is a mix of deprecated and non-deprecated imports + // context.report({ + // node, + // message: 'Import deprecated components from @primer/react/deprecated', + // *fix(fixer) { + // for (const specifier of deprecated) { + // yield fixer.remove(specifier) + // const comma = sourceCode.getTokenAfter(specifier) + // if (comma.value === ',') { + // yield fixer.remove(comma) + // } + // } + // if (deprecatedEntrypoint) { + // const lastSpecifier = deprecatedEntrypoint.specifiers[deprecatedEntrypoint.specifiers.length - 1] + // yield fixer.insertTextAfter( + // lastSpecifier, + // `, ${deprecated.map(specifier => specifier.imported.name).join(', ')}`, + // ) + // } else { + // yield fixer.insertTextAfter( + // node, + // `\nimport {${deprecated + // .map(specifier => specifier.imported.name) + // .join(', ')}} from '@primer/react/deprecated'`, + // ) + // } + // }, + // }) + } + }, + } + }, +}