diff --git a/packages/eslint-plugin/README.md b/packages/eslint-plugin/README.md new file mode 100644 index 000000000..2dcfda653 --- /dev/null +++ b/packages/eslint-plugin/README.md @@ -0,0 +1,3 @@ +# @codeshift/eslint-plugin + +Placeholder diff --git a/packages/eslint-plugin/lib/__tests__/no-codemod-comment.test.ts b/packages/eslint-plugin/lib/__tests__/no-codemod-comment.test.ts new file mode 100644 index 000000000..f36ec5436 --- /dev/null +++ b/packages/eslint-plugin/lib/__tests__/no-codemod-comment.test.ts @@ -0,0 +1,71 @@ +import { RuleTester } from 'eslint'; + +import rule from '../rules/no-codemod-comment'; + +const ruleTester = new RuleTester({ + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { + jsx: true, + }, + }, +}); + +ruleTester.run('no-codemod-comment', rule, { + valid: [ + { + code: [ + // This has an invalid header so is ignored + `/* integrity: codemod-hash-1399692252 */`, + `// TODO codemod-generated-comment signed: codemod-hash-1399692252`, + ``, + ].join('\n'), + }, + { + code: [``].join('\n'), + }, + ], + invalid: [ + { + code: [ + `/* AUTOGENERATED CODEMOD SIGNATURE signed: */`, + `// TODO: This is a codemod generated comment.`, + ``, + ` `, + ``, + ].join('\n'), + errors: [{ messageId: 'noHashMatch' }], + }, + { + code: [ + `/* AUTOGENERATED CODEMOD SIGNATURE signed: codemod-hash-1510141432 */`, + `// TODO: This is a codemod generated comment.`, + ``, + ].join('\n'), + errors: [{ messageId: 'noHashMatch' }], + }, + { + code: [ + `/* AUTOGENERATED CODEMOD SIGNATURE signed: codemod-hash-524539434,codemod-hash-2056612822 */`, + `// TODO: This is a codemod generated comment.`, + `const y = `, + `// TODO: This is a codemod generated comment. Another comment`, + `const x = `, + ].join('\n'), + errors: [{ messageId: 'noHashInSource' }, { messageId: 'noHashMatch' }], + }, + { + code: [ + `/* AUTOGENERATED CODEMOD SIGNATURE signed: codemod-hash-524539434,codemod-hash-2056612820 */`, + `// TODO: This is a codemod generated comment.`, + `const y = `, + `// TODO: This is a codemod generated comment. Another comment`, + `const x = `, + ].join('\n'), + errors: [ + { messageId: 'noHashInSource' }, + { messageId: 'noHashInSource' }, + ], + }, + ], +}); diff --git a/packages/eslint-plugin/lib/__tests__/rename-prop.test.ts b/packages/eslint-plugin/lib/__tests__/rename-prop.test.ts new file mode 100644 index 000000000..7d2492bb4 --- /dev/null +++ b/packages/eslint-plugin/lib/__tests__/rename-prop.test.ts @@ -0,0 +1,164 @@ +import { RuleTester } from 'eslint'; + +import rule, { UpdatePropNameOptions } from '../rules/rename-prop'; + +const ruleTester = new RuleTester({ + parserOptions: { + sourceType: 'module', + ecmaVersion: 'latest', + ecmaFeatures: { + jsx: true, + }, + }, +}); + +ruleTester.run('jsx/update-prop-name', rule, { + valid: [ + { + options: [ + { + source: '@atlaskit/modal-dialog', + specifier: 'Modal', + oldProp: 'open', + newProp: 'isOpen', + }, + ] as UpdatePropNameOptions[], + code: ` + import { Modal as AKModal } from '@atlaskit/modal-dialog' + + const App = () => ( + + + + ) +`, + }, + { + code: ` +const App = () => ( + + + +) +`, + }, + ], + invalid: [ + { + options: [ + { + source: '@atlaskit/modal-dialog', + specifier: 'Modal', + oldProp: 'open', + newProp: 'isOpen', + }, + ] as UpdatePropNameOptions[], + code: ` +import { Modal } from '@atlaskit/modal-dialog' + +const App = () => ( + + + +) +`, + errors: [{ messageId: 'renameProp' }], + output: ` +import { Modal } from '@atlaskit/modal-dialog' + +const App = () => ( + + + +) +`, + }, + { + options: [ + { + source: '@atlaskit/modal-dialog', + specifier: 'Modal', + oldProp: 'open', + newProp: 'isOpen', + }, + ] as UpdatePropNameOptions[], + code: ` +import { Modal as AKModal } from '@atlaskit/modal-dialog' + +const App = () => ( + + + +) +`, + errors: [{ messageId: 'renameProp' }], + output: ` +import { Modal as AKModal } from '@atlaskit/modal-dialog' + +const App = () => ( + + + +) +`, + }, + { + options: [ + { + source: '@example/thing', + specifier: 'Checkbox', + oldProp: 'selected', + newProp: 'checked', + }, + ] as UpdatePropNameOptions[], + code: ` +import { Checkbox } from '@example/thing' + +const App = () => ( + + + +) +`, + errors: [{ messageId: 'renameProp' }], + output: ` +import { Checkbox } from '@example/thing' + +const App = () => ( + + + +) +`, + }, + { + options: [ + { + source: '@example/thing', + specifier: 'default', + oldProp: 'selected', + newProp: 'checked', + }, + ] as UpdatePropNameOptions[], + code: ` +import Checkbox from '@example/thing' + +const App = () => ( + + + +) +`, + errors: [{ messageId: 'renameProp' }], + output: ` +import Checkbox from '@example/thing' + +const App = () => ( + + + +) +`, + }, + ], +}); diff --git a/packages/eslint-plugin/lib/hash.ts b/packages/eslint-plugin/lib/hash.ts new file mode 100644 index 000000000..e9e6d1434 --- /dev/null +++ b/packages/eslint-plugin/lib/hash.ts @@ -0,0 +1,17 @@ +/** + * + * Example hash format + * @example + * ```ts + * // TODO: This file has been altered by a codemod. integrity: codemod-hash-5131781 + * ``` + */ +export function hash(str: string) { + let hashValue = 0; + for (let i = 0; i < str.length; i++) { + const char = str.charCodeAt(i); + hashValue = (hashValue << 5) - hashValue + char; + hashValue = hashValue & hashValue; // Convert to 32bit integer + } + return `codemod-hash-${Math.abs(hashValue)}`; +} diff --git a/packages/eslint-plugin/lib/index.ts b/packages/eslint-plugin/lib/index.ts new file mode 100644 index 000000000..eaf0dadda --- /dev/null +++ b/packages/eslint-plugin/lib/index.ts @@ -0,0 +1,17 @@ +import renameProp from './rules/rename-prop'; +import noCodemodComment from './rules/no-codemod-comment'; + +export const rules = { + /** + * Remove or update a jsx prop + */ + 'jsx/update-prop-name': renameProp, + /** + * Remove or update import + */ + 'update-import': renameProp, + /** + * Has codemod TODO + */ + 'no-codemod-comment': noCodemodComment, +}; diff --git a/packages/eslint-plugin/lib/rules/no-codemod-comment.ts b/packages/eslint-plugin/lib/rules/no-codemod-comment.ts new file mode 100644 index 000000000..ebffed9be --- /dev/null +++ b/packages/eslint-plugin/lib/rules/no-codemod-comment.ts @@ -0,0 +1,77 @@ +import type { Rule } from 'eslint'; +import { hash } from '../hash'; + +const SIGNATURE_HEADER = 'AUTOGENERATED CODEMOD SIGNATURE'; +const TODO_COMMENT = 'TODO: This is a codemod generated comment.'; +const HEADER_REGEX = /(codemod-hash-\d+)/g; + +/** + * If there is the presence of a header we then check all comments to verify if they have matching hashes with the header + */ +const rule: Rule.RuleModule = { + meta: { + type: 'suggestion', + docs: { + description: 'Errors if a block has a codemod generated comment in it', + recommended: true, + }, + messages: { + noHashInSource: + 'The file {{ file }} includes a comment generated by a codemod. This comment requires further manual verification.', + noHashMatch: + 'The file {{ file }} includes a comment generated by a codemod but its hash <{{expectedHashValue}}> does not match the header <{{currentHashValue}}>. Please rerun the codemod, or if the codemod changes are now verified - remove the comment and header from the file.', + }, + }, + create(context) { + const filename = context.getFilename(); + const source = context.getSourceCode(); + const comments = source.getAllComments(); + const headerComment = comments.find(comment => + comment.value.includes(SIGNATURE_HEADER), + ); + + return { + Program() { + if (!headerComment) { + return; + } + + const headerSignatureMatches = Array.from( + headerComment.value.matchAll(HEADER_REGEX), + ); + const codemodComments = comments.filter(comment => + comment.value.includes(TODO_COMMENT), + ); + + codemodComments.forEach((com, index) => { + const currentHashValue = hash(com.value); + const expectedHashValue = headerSignatureMatches[index] + ? headerSignatureMatches[index][0] + : ''; + + if (currentHashValue !== expectedHashValue) { + context.report({ + loc: com.loc!, + messageId: 'noHashMatch', + data: { + file: filename, + expectedHashValue, + currentHashValue, + }, + }); + } else { + context.report({ + loc: com.loc!, + messageId: 'noHashInSource', + data: { + file: filename, + }, + }); + } + }); + }, + }; + }, +}; + +export default rule; diff --git a/packages/eslint-plugin/lib/rules/rename-prop.ts b/packages/eslint-plugin/lib/rules/rename-prop.ts new file mode 100644 index 000000000..2310f07b6 --- /dev/null +++ b/packages/eslint-plugin/lib/rules/rename-prop.ts @@ -0,0 +1,149 @@ +import type { Rule } from 'eslint'; +import { + ImportDeclaration, + jsxAttribute, + JSXAttribute, + jsxIdentifier, + JSXOpeningElement, + RuleListener, +} from 'eslint-codemod-utils'; + +export interface UpdatePropNameOptions { + source: string; + specifier: string; + oldProp: string; + newProp: string; +} + +const rule: Rule.RuleModule = { + meta: { + type: 'suggestion', + docs: { + description: + 'Dummy rule that changes a prop name in a dummy component using ast-helpers', + recommended: true, + }, + messages: { + renameProp: + 'The prop "{{ oldProp }}" in <{{ local }} /> has been renamed to "{{ newProp }}".', + }, + fixable: 'code', + schema: { + description: 'Change any prop to another prop using eslint', + type: 'array', + items: { + type: 'object', + required: ['source', 'specifier', 'oldProp', 'newProp'], + properties: { + source: { + type: 'string', + description: 'The source path of the JSXElement import.', + }, + specifier: { + type: 'string', + description: + "The import specifier of the JSXElement being targeted - can also be simply 'default'.", + }, + oldProp: { + type: 'string', + description: 'The old name of the JSX attribute', + }, + newProp: { + type: 'string', + description: 'The new name of the JSX attribute', + }, + }, + }, + }, + }, + // @ts-ignore + create(context): RuleListener { + const config = context.options as UpdatePropNameOptions[]; + let importDecs: ImportDeclaration[] | null[] = config.map(() => null); + + function renameProp( + node: JSXOpeningElement, + importDec: ImportDeclaration, + option: UpdatePropNameOptions, + ) { + const specifier = importDec.specifiers.find( + spec => + (spec.type === 'ImportSpecifier' && + spec.imported.name === option.specifier) || + (spec.type === 'ImportDefaultSpecifier' && + option.specifier === 'default'), + ); + + // The element is imported for a different reason + if (!specifier) { + return; + } + + if ( + !( + node.name.type === 'JSXIdentifier' && + node.name.name === specifier.local.name + ) + ) { + return; + } + + const toChangeAttr = node.attributes.find( + (attr): attr is JSXAttribute => { + if (attr.type === 'JSXAttribute') { + return attr.name.name === option.oldProp; + } + + return false; + }, + ); + + if (!toChangeAttr) { + return; + } + + // Error cases after this point + context.report({ + // @ts-ignore + node: toChangeAttr, + messageId: 'renameProp', + data: { ...option, local: specifier.local.name }, + fix(fixer) { + const fixed = jsxAttribute({ + ...toChangeAttr, + name: jsxIdentifier({ name: option.newProp }), + }); + + // @ts-ignore node doesn't have correct type infererence + return fixer.replaceText(toChangeAttr, `${fixed}`); + }, + }); + } + + return { + // @ts-ignore + 'Program:exit': () => { + config.forEach((_, index) => { + importDecs[index] = null; + }); + importDecs = []; + }, + ImportDeclaration(node) { + config.forEach((c, i) => { + if (c.source === node.source.value) { + importDecs[i] = node; + } + }); + }, + JSXOpeningElement(node) { + config.forEach((c, i) => { + if (importDecs[i]) { + renameProp(node, importDecs[i]!, config[i]); + } + }); + }, + }; + }, +}; + +export default rule; diff --git a/packages/eslint-plugin/package.json b/packages/eslint-plugin/package.json new file mode 100644 index 000000000..3073db77c --- /dev/null +++ b/packages/eslint-plugin/package.json @@ -0,0 +1,17 @@ +{ + "name": "eslint-plugin-codemod", + "version": "0.0.1", + "dependencies": { + "eslint-codemod-utils": "latest" + }, + "source": "src/index.ts", + "main": "dist/index.js", + "scripts": { + "build": "tsc", + "test": "vitest" + }, + "devDependencies": { + "@types/eslint": "^8.4.1", + "typescript": "^4.5.5" + } +} \ No newline at end of file diff --git a/packages/eslint-plugin/tsconfig.json b/packages/eslint-plugin/tsconfig.json new file mode 100644 index 000000000..0402e30aa --- /dev/null +++ b/packages/eslint-plugin/tsconfig.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.json", + "include": ["lib/**/*"], + "compilerOptions": { + "outDir": "dist" + } +}