Skip to content

Commit

Permalink
feat(codemods): add @suspensive/codemods package (#1338)
Browse files Browse the repository at this point in the history
# Overview

I have designed a codemods CLI that allows you to select and execute
various transforms (codemods).

Currently, only the `tanstack-query-import` codemod has been
implemented, but we plan to add more codemods that facilitate version
upgrades and make codebase maintenance easier. For example:

- suspensive v1 → v2 break change codemod
- suspensive v2 → v3 break change  codemod

## tanstack-query-import

> Migrate imports to @tanstack/react-query in @suspensive/react-query
(using @tanstack/react-query@5)

I’ve implemented the `tanstack-query-import` codemod. This tool migrates
deprecated APIs from `@suspensive/react-query-5` to
`@tanstack/react-query` by simply replacing imports. It helps facilitate
an easy migration when updating from `@suspensive/react-query-4` to
`@suspensive/react-query-5`.

### Preview


https://github.com/user-attachments/assets/4140416d-fcf7-437f-bb24-35f918afcd70

## PR Checklist

- [x] I did below actions if need

1. I read the [Contributing
Guide](https://github.com/toss/suspensive/blob/main/CONTRIBUTING.md)
2. I added documents and tests.

---------

Co-authored-by: Jonghyeon Ko <[email protected]>
  • Loading branch information
gwansikk and manudeli authored Nov 3, 2024
1 parent 0f81956 commit 2fba62c
Show file tree
Hide file tree
Showing 18 changed files with 615 additions and 27 deletions.
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/bug.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ body:
- "@suspensive/react-query-4"
- "@suspensive/react-query-5"
- "@suspensive/jotai"
- "@suspensive/codemods"
- etc
validations:
required: true
Expand Down
1 change: 1 addition & 0 deletions .github/ISSUE_TEMPLATE/feature_request.yml
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ body:
- "@suspensive/react-query-4"
- "@suspensive/react-query-5"
- "@suspensive/jotai"
- "@suspensive/codemods"
- etc
validations:
required: true
Expand Down
2 changes: 2 additions & 0 deletions .github/labeler.yml
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@
- "packages/react-query-5/**/*"
"@suspensive/jotai":
- "packages/jotai/**/*"
"@suspensive/codemods":
- "packages/codemods/**/*"
"suspensive.org":
- "docs/suspensive.org/**/*"
"examples":
Expand Down
4 changes: 4 additions & 0 deletions codecov.yml
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,7 @@ component_management:
name: '@suspensive/jotai'
paths:
- packages/jotai/**
- component_id: codemods
name: '@suspensive/codemods'
paths:
- packages/codemods/**
11 changes: 11 additions & 0 deletions packages/codemods/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
# Introduction

Suspensive provides a codemod to facilitate easy code modifications. This helps in managing the Suspensive codebase more effectively.

## Usage

In your terminal, navigate into your project's folder, then run:

```shell
npx @suspensive/codemods <transform> <path>
```
3 changes: 3 additions & 0 deletions packages/codemods/eslint.config.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { suspensiveTypeScriptConfig } from '@suspensive/eslint-config'

export default [...suspensiveTypeScriptConfig]
51 changes: 51 additions & 0 deletions packages/codemods/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
{
"name": "@suspensive/codemods",
"version": "0.1.0",
"description": "Codemods for @suspensive.",
"keywords": [
"suspensive",
"codemods"
],
"homepage": "https://suspensive.org",
"bugs": "https://github.com/toss/suspensive/issues",
"repository": {
"type": "git",
"url": "git+https://github.com/toss/suspensive.git",
"directory": "packages/codemods"
},
"license": "MIT",
"author": "Gwansik Kim <[email protected]>",
"sideEffects": false,
"type": "module",
"files": [
"dist"
],
"scripts": {
"build": "tsup",
"ci:attw": "attw --pack",
"ci:eslint": "eslint \"**/*.{ts,tsx,cts,mts}\"",
"ci:publint": "publint --strict",
"ci:test": "vitest run --coverage --typecheck",
"ci:type": "tsc --noEmit",
"clean": "rimraf ./dist && rimraf ./coverage",
"prepack": "pnpm build",
"test:ui": "vitest --ui --coverage --typecheck"
},
"bin": "dist/bin/codemods.cjs",
"dependencies": {
"@commander-js/extra-typings": "^12.1.0",
"commander": "^12.1.0",
"execa": "^5.1.1",
"jscodeshift": "^17.0.0",
"prompts": "^2.4.2"
},
"devDependencies": {
"@suspensive/eslint-config": "workspace:*",
"@suspensive/tsconfig": "workspace:*",
"@types/jscodeshift": "^0.12.0",
"@types/prompts": "^2.4.9"
},
"publishConfig": {
"access": "public"
}
}
19 changes: 19 additions & 0 deletions packages/codemods/src/bin/codemods.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import { execFileSync } from 'node:child_process'
import path from 'node:path'
import packageJson from '../../package.json'

const codemodsPath = path.resolve(__dirname, '../../dist/bin/codemods.cjs')

Check warning on line 5 in packages/codemods/src/bin/codemods.spec.ts

View workflow job for this annotation

GitHub Actions / Check quality (ci:eslint)

Unknown word: "codemods"

Check warning on line 5 in packages/codemods/src/bin/codemods.spec.ts

View workflow job for this annotation

GitHub Actions / Check quality (ci:eslint)

Unknown word: "codemods"

describe('codemods', () => {
it('should display the correct version when using the -v flag', () => {
const result = execFileSync('node', [codemodsPath, '-v']).toString().trim()

expect(result).toBe(packageJson.version)
})

it('should display the help message when using the -h flag', () => {
const result = execFileSync('node', [codemodsPath, '-h']).toString()

expect(result).toContain('Usage: @suspensive/codemods [codemod] [path]')
})
})
26 changes: 26 additions & 0 deletions packages/codemods/src/bin/codemods.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
#!/usr/bin/env node
/**
* Copyright 2015-present, Facebook, Inc.
*
* This source code is licensed under the MIT license found in the
* LICENSE file in the root directory of this source tree.
*
*/
// Based on https://github.com/reactjs/react-codemod

import { Command } from '@commander-js/extra-typings'
import packageJson from '../../package.json'
import { transformRunner } from './transformRunner'

const program = new Command(packageJson.name)

program
.description(packageJson.description)
.version(packageJson.version, '-v, --version', 'Output the current version of @suspensive/codemods.')
.argument('[codemod]', 'Codemod slug to run.')
.argument('[path]', 'Path to source directory.')
.usage('[codemod] [path]')
.helpOption('-h, --help', 'Display this help message.')
.action((codemod, path) => transformRunner(codemod, path))

program.parse(process.argv)
48 changes: 48 additions & 0 deletions packages/codemods/src/bin/transformRunner.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,48 @@
import fs from 'node:fs'
import path from 'node:path'
import { jscodeshiftExecutable, transformRunner, transformerDirectory } from './transformRunner'

let mockExecaReturnValue: { failed: boolean; exitCode: number }

vi.mock('execa', () => ({
default: () => {
if (mockExecaReturnValue.failed) {
const error = new Error(`jscodeshift exited with code ${mockExecaReturnValue.exitCode}`)
;(error as any).exitCode = mockExecaReturnValue.exitCode
throw error
}
return { failed: false, exitCode: 0 }
},
}))

describe('transformRunner', () => {
beforeEach(() => {
mockExecaReturnValue = { failed: false, exitCode: 0 }
console.log = vi.fn()
})

it('finds transformer directory', () => {
const status = fs.lstatSync(transformerDirectory)
expect(status).toBeTruthy()
})

it('finds jscodeshift executable', () => {
const status = fs.lstatSync(jscodeshiftExecutable)
expect(status).toBeTruthy()
})

it('runs jscodeshift for the given transformer', async () => {
await transformRunner('tanstack-query-import', 'src')
expect(console.log).toBeCalledWith(
`Executing command: jscodeshift --no-babel --ignore-pattern=**/node_modules/** --ignore-pattern=**/.next/** --extensions=tsx,ts,jsx,js --transform ${path.join(
transformerDirectory,
'tanstack-query-import.cjs'
)} src`
)
})

it('rethrows jscodeshift errors', async () => {
mockExecaReturnValue = { failed: true, exitCode: 1 }
await expect(transformRunner('test', 'src')).rejects.toThrowError('process.exit unexpectedly called with "1"')
})
})
117 changes: 117 additions & 0 deletions packages/codemods/src/bin/transformRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
import { join } from 'node:path'
import execa from 'execa'
import prompts from 'prompts'

const TRANSFORMER_INQUIRER_CHOICES = [
{
title: 'tanstack-query-import',
description: 'Migrate imports to @tanstack/react-query in @suspensive/react-query (using @tanstack/react-query@5)',
},
]

function onCancel() {
process.exit(1)
}

export const jscodeshiftExecutable = require.resolve('.bin/jscodeshift')
export const transformerDirectory = join(__dirname, '../', '../', 'dist', 'transforms')

export async function transformRunner(transform?: string, path?: string) {
let transformer: string = transform ?? ''
let directory: string = path ?? ''

if (transform && !TRANSFORMER_INQUIRER_CHOICES.find((x) => x.title === transform)) {
console.error('Invalid transform choice, pick one of:')
console.error(TRANSFORMER_INQUIRER_CHOICES.map((x) => '- ' + x.title).join('\n'))
process.exit(1)
}

if (!transform) {
const res = await prompts(
{
type: 'select',
name: 'transformer',
message: 'Which transform would you like to apply?',
choices: TRANSFORMER_INQUIRER_CHOICES.reverse().map(({ title, description }) => {
return {
title,
description,
value: title,
}
}),
},
{ onCancel }
)

transformer = res.transformer as string
}

if (!path) {
const res = await prompts(
{
type: 'text',
name: 'path',
message: 'On which files or directory should the codemods be applied?',
initial: '.',
},
{ onCancel }
)

directory = res.path as string
}

const args: Array<string> = []

args.push('--no-babel')
args.push('--ignore-pattern=**/node_modules/**')
args.push('--ignore-pattern=**/.next/**')
args.push('--extensions=tsx,ts,jsx,js')

args.push('--transform', join(transformerDirectory, `${transformer}.cjs`))
args.push(directory)

console.log(`Executing command: jscodeshift ${args.join(' ')}`)

const execaChildProcess = execa(jscodeshiftExecutable, args, {
env: process.stdout.isTTY ? { FORCE_COLOR: 'true' } : {},
})

// "\n" + "a\n" + "b\n"
let lastThreeLineBreaks = ''

if (execaChildProcess.stdout) {
execaChildProcess.stdout.pipe(process.stdout)
execaChildProcess.stderr?.pipe(process.stderr)

// The last two lines contain the successful transformation count as "N ok".
// To save memory, we "slide the window" to keep only the last three line breaks.
// We save three line breaks because the EOL is always "\n".
execaChildProcess.stdout.on('data', (chunk: Buffer) => {
lastThreeLineBreaks += chunk.toString('utf-8')

let cutoff = lastThreeLineBreaks.length

// Note: the stdout ends with "\n".
// "foo\n" + "bar\n" + "baz\n" -> "\nbar\nbaz\n"
// "\n" + "foo\n" + "bar\n" -> "\nfoo\nbar\n"
for (let i = 0; i < 3; i++) {
cutoff = lastThreeLineBreaks.lastIndexOf('\n', cutoff) - 1
}

if (cutoff > 0 && cutoff < lastThreeLineBreaks.length) {
lastThreeLineBreaks = lastThreeLineBreaks.slice(cutoff + 1)
}
})
}

try {
const result = await execaChildProcess

if (result.failed) {
throw new Error(`jscodeshift exited with code ${result.exitCode}`)
}
} catch (error) {
console.error(error)
process.exit(1)
}
}
60 changes: 60 additions & 0 deletions packages/codemods/src/transforms/tanstack-query-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,60 @@
import type { FileInfo } from 'jscodeshift'
import { createParserFromPath } from '../utils/createParserFromPath'

const IMPORT_TO_CHANGE = [
'useSuspenseQuery',
'UseSuspenseQueryResult',
'UseSuspenseQueryOptions',
'useSuspenseQueries',
'SuspenseQueriesResults',
'SuspenseQueriesOptions',
'useSuspenseInfiniteQuery',
'UseSuspenseInfiniteQueryResult',
'UseSuspenseInfiniteQueryOptions',
'usePrefetchQuery',
'queryOptions',
'infiniteQueryOption',
]

export default function transform(file: FileInfo): string {
const j = createParserFromPath(file.path)
const root = j(file.source)

root
.find(j.ImportDeclaration, {
source: {
value: (v: string) => /^@suspensive\/react-query(-\d+)?$/.test(v),
},
})
.forEach((path) => {
const importSpecifiers = path.node.specifiers || []

const importNamesToChange = importSpecifiers.filter((specifier) =>
IMPORT_TO_CHANGE.includes(specifier.local?.name ?? '')
)
const importsNamesRemained = importSpecifiers.filter(
(specifier) => !IMPORT_TO_CHANGE.includes(specifier.local?.name ?? '')
)

if (importNamesToChange.length > 0) {
const newImportStatement = j.importDeclaration(importNamesToChange, j.stringLiteral('@tanstack/react-query'))
path.insertBefore(newImportStatement)
}
if (importsNamesRemained.length > 0) {
const remainingSpecifiers = importSpecifiers.filter(
(specifier) => !IMPORT_TO_CHANGE.includes(specifier.local?.name ?? '')
)

const suspensiveRemainImportsStatement = j.importDeclaration(
remainingSpecifiers,
j.stringLiteral((path.node.source.value as string) || '@suspensive/react-query')
)
path.insertBefore(suspensiveRemainImportsStatement)
}

j(path).remove()
})
.toSource()

return root.toSource()
}
Loading

0 comments on commit 2fba62c

Please sign in to comment.