Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: introduce eslint plugin #121

Open
wants to merge 1 commit into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions packages/eslint-plugin/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
# @codeshift/eslint-plugin

Placeholder
71 changes: 71 additions & 0 deletions packages/eslint-plugin/lib/__tests__/no-codemod-comment.test.ts
Original file line number Diff line number Diff line change
@@ -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`,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Could you explain how the integrity check works?

So if I was to imagine a codemod run on a repo and the diffs raised in a PR, would these be presented as an error? Or can they be merged and subsequent changes to the file would trigger an error?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@danieldelcore I should add a README for these rules sorry mate, will amend.

In essence the idea is that a codemod would add a header comment and generate hashes off the comments it introduces to code. Each of these comments are validated against the header. So it's clear that this were generated by a script.

I thought of multiple different approaches here tho for example:

  • Using a hash of the whole file from the time that the codemod ran and storing it in a header, then recalculating the hash off the whole file minus the hash. Idea here would be as soon as you make a change in this file it will error. IE don't leave the codemod header / make a change without validating that you've acknowledged the codemod ran.
  • Alternatively just erroring or warning off comments generated by codemod.

Or some combination of the above.

`<Hello />`,
].join('\n'),
},
{
code: [`<Hello />`].join('\n'),
},
],
invalid: [
{
code: [
`/* AUTOGENERATED CODEMOD SIGNATURE signed: */`,
`// TODO: This is a codemod generated comment.`,
`<Hello>`,
` <AKModal open={false} />`,
`</Hello>`,
].join('\n'),
errors: [{ messageId: 'noHashMatch' }],
},
{
code: [
`/* AUTOGENERATED CODEMOD SIGNATURE signed: codemod-hash-1510141432 */`,
`// TODO: This is a codemod generated comment.`,
`<Hello />`,
].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 = <Hello />`,
`// TODO: This is a codemod generated comment. Another comment`,
`const x = <Hello />`,
].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 = <Hello />`,
`// TODO: This is a codemod generated comment. Another comment`,
`const x = <Hello />`,
].join('\n'),
errors: [
{ messageId: 'noHashInSource' },
{ messageId: 'noHashInSource' },
],
},
],
});
164 changes: 164 additions & 0 deletions packages/eslint-plugin/lib/__tests__/rename-prop.test.ts
Original file line number Diff line number Diff line change
@@ -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, {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

How do you see jsx/update-prop-name being used? Is this something someone would configure in their eslintrc, run and then remove from the config?

Or maybe would they have multiple configurations setup, one for modal-dialog, one for button etc then they remain in the eslintfile and can be updated as the version changes?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think yeah it could run alongside / with / in place of / any codemod approach. Maybe a codemod could even be used to update the eslint config around releases. The flexibility of the current config is that it can be used with lots of configs and thus different modules.

The difficult part to figure out is when to turn it off or how to manage ratcheting it.

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 = () => (
<Hello>
<AKModal isOpen={false} />
</Hello>
)
`,
},
{
code: `
const App = () => (
<Hello>
<AKModal open={false} />
</Hello>
)
`,
},
],
invalid: [
{
options: [
{
source: '@atlaskit/modal-dialog',
specifier: 'Modal',
oldProp: 'open',
newProp: 'isOpen',
},
] as UpdatePropNameOptions[],
code: `
import { Modal } from '@atlaskit/modal-dialog'

const App = () => (
<Hello>
<Modal open={false} />
</Hello>
)
`,
errors: [{ messageId: 'renameProp' }],
output: `
import { Modal } from '@atlaskit/modal-dialog'

const App = () => (
<Hello>
<Modal isOpen={false} />
</Hello>
)
`,
},
{
options: [
{
source: '@atlaskit/modal-dialog',
specifier: 'Modal',
oldProp: 'open',
newProp: 'isOpen',
},
] as UpdatePropNameOptions[],
code: `
import { Modal as AKModal } from '@atlaskit/modal-dialog'

const App = () => (
<Hello>
<AKModal open={false} />
</Hello>
)
`,
errors: [{ messageId: 'renameProp' }],
output: `
import { Modal as AKModal } from '@atlaskit/modal-dialog'

const App = () => (
<Hello>
<AKModal isOpen={false} />
</Hello>
)
`,
},
{
options: [
{
source: '@example/thing',
specifier: 'Checkbox',
oldProp: 'selected',
newProp: 'checked',
},
] as UpdatePropNameOptions[],
code: `
import { Checkbox } from '@example/thing'

const App = () => (
<Hello>
<Checkbox selected={false} />
</Hello>
)
`,
errors: [{ messageId: 'renameProp' }],
output: `
import { Checkbox } from '@example/thing'

const App = () => (
<Hello>
<Checkbox checked={false} />
</Hello>
)
`,
},
{
options: [
{
source: '@example/thing',
specifier: 'default',
oldProp: 'selected',
newProp: 'checked',
},
] as UpdatePropNameOptions[],
code: `
import Checkbox from '@example/thing'

const App = () => (
<Hello>
<Checkbox selected={false} />
</Hello>
)
`,
errors: [{ messageId: 'renameProp' }],
output: `
import Checkbox from '@example/thing'

const App = () => (
<Hello>
<Checkbox checked={false} />
</Hello>
)
`,
},
],
});
17 changes: 17 additions & 0 deletions packages/eslint-plugin/lib/hash.ts
Original file line number Diff line number Diff line change
@@ -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)}`;
}
17 changes: 17 additions & 0 deletions packages/eslint-plugin/lib/index.ts
Original file line number Diff line number Diff line change
@@ -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,
};
77 changes: 77 additions & 0 deletions packages/eslint-plugin/lib/rules/no-codemod-comment.ts
Original file line number Diff line number Diff line change
@@ -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;
Loading