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"
+ }
+}