Skip to content

Commit

Permalink
feat(codemods): add @suspensive/codemods package
Browse files Browse the repository at this point in the history
  • Loading branch information
gwansikk committed Oct 29, 2024
1 parent 7b9c6a1 commit 4521f82
Show file tree
Hide file tree
Showing 10 changed files with 516 additions and 30 deletions.
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]> & Jonghyeon Ko <[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/suspensive-codemod.cjs",
"dependencies": {
"commander": "^12.1.0",
"execa": "^4.1.0",
"jscodeshift": "^17.0.0",
"prompts": "^2.4.2"
},
"devDependencies": {
"@commander-js/extra-typings": "^12.1.0",
"@suspensive/eslint-config": "workspace:*",
"@suspensive/tsconfig": "workspace:*",
"@types/jscodeshift": "^0.12.0",
"@types/prompts": "^2.4.9"
},
"publishConfig": {
"access": "public"
}
}
17 changes: 17 additions & 0 deletions packages/codemods/src/suspensive-codemod.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
#!/usr/bin/env node

import { Command } from 'commander'
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]')
.action(transformRunner)

program.parse(process.argv)
115 changes: 115 additions & 0 deletions packages/codemods/src/transformRunner.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
import { join } from 'node:path'
import execa from 'execa'
import prompts from 'prompts'
import { jscodeshiftExecutable, onCancel } from './utils'

export const TRANSFORMER_INQUIRER_CHOICES = [
{
title: 'react-query-import',
description: 'Safely migrate @tanstack/react-query',
},
]

export const transformerDirectory = join(__dirname, '../', 'dist', 'transforms')

export async function transformRunner(transform: string, path: string) {
let transformer = transform
let directory = 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
}

let args = []

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

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

console.log(`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)
}
}
59 changes: 59 additions & 0 deletions packages/codemods/src/transforms/react-query-import.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,59 @@
import type j from 'jscodeshift'
import { createParserFromPath } from '../utils'

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

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

root
.find(j.ImportDeclaration, {
source: {
value: '@suspensive/react-query',
},
})
.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 suspensiveReactQueryRemainImportsStatement = j.importDeclaration(
remainingSpecifiers,
j.stringLiteral('@suspensive/react-query')
)
path.insertBefore(suspensiveReactQueryRemainImportsStatement)
}
j(path).remove()
})
.toSource()

return root.toSource()
}
34 changes: 34 additions & 0 deletions packages/codemods/src/utils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import j from 'jscodeshift'
// @ts-expect-error: Declaration files are not included
import babylonParse from 'jscodeshift/parser/babylon'
// @ts-expect-error: Declaration files are not included
import tsOptions from 'jscodeshift/parser/tsOptions'

export const jscodeshiftExecutable = require.resolve('.bin/jscodeshift')

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

export function createParserFromPath(filePath: string): j.JSCodeshift {
const isDeclarationFile = /\.d\.(m|c)?ts$/.test(filePath)
if (isDeclarationFile) {
return j.withParser(
// eslint-disable-next-line @typescript-eslint/no-unsafe-argument, @typescript-eslint/no-unsafe-call
babylonParse({
...tsOptions,
plugins: [
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment, @typescript-eslint/no-unsafe-call, @typescript-eslint/no-unsafe-member-access
...tsOptions.plugins.filter((plugin: string) => plugin !== 'typescript'),
['typescript', { dts: true }],
],
})
)
}

// jsx is allowed in .js files, feed them into the tsx parser.
// tsx parser :.js, .jsx, .tsx
// ts parser: .ts, .mts, .cts
const isTsFile = /\.(m|c)?.ts$/.test(filePath)
return isTsFile ? j.withParser('ts') : j.withParser('tsx')
}
7 changes: 7 additions & 0 deletions packages/codemods/tsconfig.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
{
"extends": "@suspensive/tsconfig/base.json",
"compilerOptions": {
"types": ["vitest/globals"]
},
"include": ["**/*.ts"]
}
9 changes: 9 additions & 0 deletions packages/codemods/tsup.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { defineConfig } from 'tsup'

export default defineConfig({
format: 'cjs',
target: ['node18'],
entry: ['src/**/*.{ts,tsx}', '!**/*.{spec,test,test-d,bench}.*'],
outDir: 'dist',
external: ['.bin/jscodeshift'],
})
13 changes: 13 additions & 0 deletions packages/codemods/vitest.config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { defineConfig } from 'vitest/config'
import packageJson from './package.json'

export default defineConfig({
test: {
name: packageJson.name,
dir: './src',
globals: true,
coverage: {
provider: 'istanbul',
},
},
})
Loading

0 comments on commit 4521f82

Please sign in to comment.