diff --git a/package-lock.json b/package-lock.json index a9f400c6..33b7f722 100644 --- a/package-lock.json +++ b/package-lock.json @@ -500,6 +500,10 @@ "resolved": "recipes/repl-classes-with-new", "link": true }, + "node_modules/@nodejs/rimraf-to-fs-rm": { + "resolved": "recipes/rimraf-to-fs-rm", + "link": true + }, "node_modules/@nodejs/rmdir": { "resolved": "recipes/rmdir", "link": true @@ -954,6 +958,17 @@ "@codemod.com/jssg-types": "^1.6.1" } }, + "recipes/rimraf-to-fs-rm": { + "name": "@nodejs/rimraf-to-fs-rm", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.1" + } + }, "recipes/rmdir": { "name": "@nodejs/rmdir", "version": "1.1.1", diff --git a/recipes/rimraf-to-fs-rm/README.md b/recipes/rimraf-to-fs-rm/README.md new file mode 100644 index 00000000..e70f2ec2 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/README.md @@ -0,0 +1,59 @@ +# Rimraf to fs.rm + +This recipe migrates straightforward `rimraf` delete calls to Node.js built-in +`fs.rm`, `fs.rmSync`, and `fs/promises.rm` APIs. + +It covers the literal delete cases for `rimraf` v3, v4, and v5 style imports: + +- default async imports from `rimraf`, `rimraf-v3`, or `rimraf-v4` +- named `rimraf` and `rimrafSync` imports from `rimraf-v5` +- callback-based literal deletes +- promise-based literal deletes +- synchronous literal deletes +- synchronous glob deletes by expanding with `fs.globSync()` before `fs.rmSync()` +- package.json dependency removal when `rimraf` is not still used as a CLI in scripts + +## Examples + +```diff +- import rimraf from "rimraf-v4"; ++ import { rm as rmPromise } from "node:fs/promises"; + +- await rimraf("dist", { glob: false }); ++ await rmPromise("dist", { recursive: true, force: true }); +``` + +```diff +- import { rimraf, rimrafSync } from "rimraf-v5"; ++ import { rmSync } from "node:fs"; ++ import { rm as rmPromise } from "node:fs/promises"; + +- await rimraf("dist"); +- rimrafSync("build"); ++ await rmPromise("dist", { recursive: true, force: true }); ++ rmSync("build", { recursive: true, force: true }); +``` + +```diff +- import { rimrafSync } from "rimraf-v5"; ++ import { globSync, rmSync } from "node:fs"; + +- rimrafSync("dist/**/*.js"); ++ for (const filePath of globSync("dist/**/*.js")) { ++ rmSync(filePath, { recursive: true, force: true }); ++ } +``` + +## Manual review + +Some `rimraf` behavior should stay manual because it depends on runtime +semantics rather than only source syntax: + +- custom retry behavior +- custom glob options +- async glob deletes with callbacks +- Windows package-specific fallback behavior +- CLI usage, which is left untouched and may need a different migration path + +When code depends on those behaviors, keep the behavior explicit instead of +assuming full parity with native deletion APIs. diff --git a/recipes/rimraf-to-fs-rm/codemod.yaml b/recipes/rimraf-to-fs-rm/codemod.yaml new file mode 100644 index 00000000..46b71057 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/codemod.yaml @@ -0,0 +1,24 @@ +schema_version: "1.0" +name: "@nodejs/rimraf-to-fs-rm" +version: "1.0.0" +description: Migrate literal rimraf deletes to Node.js fs.rm APIs +author: Herrtian +license: MIT +workflow: workflow.yaml +category: migration +repository: https://github.com/nodejs/userland-migrations + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - nodejs + - rimraf + +registry: + access: public + visibility: public diff --git a/recipes/rimraf-to-fs-rm/package.json b/recipes/rimraf-to-fs-rm/package.json new file mode 100644 index 00000000..a37f0992 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/package.json @@ -0,0 +1,26 @@ +{ + "name": "@nodejs/rimraf-to-fs-rm", + "version": "1.0.0", + "description": "Migrate literal rimraf deletes to Node.js fs.rm APIs.", + "type": "module", + "scripts": { + "test": "node --run test:workflow && node --run test:remove-dependencies", + "test:workflow": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests", + "test:remove-dependencies": "npx codemod jssg test -l json ./src/remove-dependencies.ts ./tests/remove-dependencies --allow-child-process --allow-fs --strictness cst" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/rimraf-to-fs-rm", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Herrtian", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/rimraf-to-fs-rm/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.1" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/rimraf-to-fs-rm/src/remove-dependencies.ts b/recipes/rimraf-to-fs-rm/src/remove-dependencies.ts new file mode 100644 index 00000000..12617ffe --- /dev/null +++ b/recipes/rimraf-to-fs-rm/src/remove-dependencies.ts @@ -0,0 +1,68 @@ +import type { SgNode, Transform } from '@codemod.com/jssg-types/main'; +import type Json from '@codemod.com/jssg-types/langs/json'; +import removeDependencies from '@nodejs/codemod-utils/remove-dependencies'; + +const rimrafPackages = [ + 'rimraf', + '@types/rimraf', +]; + +/** + * Returns whether package.json scripts still call the rimraf CLI. + */ +function hasRimrafCliScript(rootNode: SgNode): boolean { + const scriptsPair = rootNode.find({ + rule: { + kind: 'pair', + all: [ + { + has: { + field: 'key', + kind: 'string', + regex: '^"scripts"$', + }, + }, + { + has: { + field: 'value', + kind: 'object', + }, + }, + ], + }, + }); + + const scriptsObject = scriptsPair?.field('value'); + if (!scriptsObject) return false; + + return Boolean( + scriptsObject.find({ + rule: { + kind: 'pair', + has: { + field: 'value', + kind: 'string', + has: { + kind: 'string_content', + regex: '\\brimraf(?:\\s|$)', + }, + }, + }, + }), + ); +} + +/** + * Removes rimraf packages when package scripts no longer need the rimraf CLI. + */ +const transform: Transform = async (root) => { + if (hasRimrafCliScript(root.root())) return null; + + return removeDependencies(rimrafPackages, { + packageJsonPath: root.filename(), + runInstall: false, + persistFileWrite: false, + }); +}; + +export default transform; diff --git a/recipes/rimraf-to-fs-rm/src/workflow.ts b/recipes/rimraf-to-fs-rm/src/workflow.ts new file mode 100644 index 00000000..a40d5b64 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/src/workflow.ts @@ -0,0 +1,233 @@ +import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; + +type BindingKind = 'async' | 'sync'; + +type Binding = { + kind: BindingKind; + name: string; +}; + +type ImportReplacement = { + usesGlobSync: boolean; + usesRm: boolean; + usesRmPromise: boolean; + usesRmSync: boolean; +}; + +const RIMRAF_SOURCE_REGEX = '^rimraf(-v[345])?$'; + +/** + * Escapes a binding name so it can be used safely in an ast-grep regex. + */ +const escapeRegex = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const getLineEnding = (source: string) => + source.includes('\r\n') ? '\r\n' : '\n'; + +/** + * Returns the top-level call arguments. + */ +const getCallArguments = (call: SgNode): string[] => { + const args = call.field('arguments'); + if (!args) return []; + + return args + .children() + .filter((child) => ![',', '(', ')'].includes(child.kind())) + .map((child) => child.text()); +}; + +/** + * Returns whether an argument is an object literal options bag. + */ +const isObjectLiteral = (value: string | undefined) => + value?.trim().startsWith('{') ?? false; + +/** + * Returns whether a literal path contains glob syntax. + */ +const isGlobLiteral = (value: string | undefined) => { + if (!value) return false; + const trimmed = value.trim(); + if (!/^["'`]/.test(trimmed)) return false; + return /[*?{}[\]]/.test(trimmed.slice(1, -1)); +}; + +/** + * Extracts rimraf and rimrafSync bindings from named imports. + */ +const parseNamedBindings = (source: string): Binding[] => { + const bindings: Binding[] = []; + const namedMatch = source.match(/{([^}]+)}/); + if (!namedMatch) return bindings; + + for (const rawSpecifier of namedMatch[1].split(',')) { + const specifier = rawSpecifier.trim(); + if (!specifier) continue; + const [importedName, alias] = specifier.split(/\s+as\s+/); + const localName = (alias || importedName).trim(); + + if (importedName.trim() === 'rimraf') { + bindings.push({ kind: 'async', name: localName }); + } + + if (importedName.trim() === 'rimrafSync') { + bindings.push({ kind: 'sync', name: localName }); + } + } + + return bindings; +}; + +/** + * Extracts default and named rimraf bindings from an import statement. + */ +const parseImportBindings = (source: string): Binding[] => { + const bindings = parseNamedBindings(source); + const defaultMatch = source.match(/^import\s+([^,{]+?)(?:\s*,|\s+from\s+)/); + const defaultName = defaultMatch?.[1]?.trim(); + + if (defaultName) { + bindings.push({ kind: 'async', name: defaultName }); + } + + return bindings; +}; + +/** + * Builds the node:fs imports required by the transformed calls. + */ +const buildImportReplacement = ( + replacement: ImportReplacement, + lineEnding: string, +) => { + const fsImports: string[] = []; + if (replacement.usesGlobSync) fsImports.push('globSync'); + if (replacement.usesRm) fsImports.push('rm'); + if (replacement.usesRmSync) fsImports.push('rmSync'); + + const lines: string[] = []; + if (fsImports.length) { + lines.push(`import { ${fsImports.join(', ')} } from "node:fs";`); + } + if (replacement.usesRmPromise) { + lines.push('import { rm as rmPromise } from "node:fs/promises";'); + } + + return lines.join(lineEnding); +}; + +/** + * Builds the recursive rm options that match rimraf's common force behavior. + */ +const buildRmOptions = () => '{ recursive: true, force: true }'; + +/** + * Converts direct rimraf calls to native fs.rm APIs. + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const lineEnding = getLineEnding(rootNode.text()); + const edits: Edit[] = []; + + const rimrafImports = rootNode.findAll({ + rule: { + kind: 'import_statement', + has: { + field: 'source', + kind: 'string', + has: { + kind: 'string_fragment', + regex: RIMRAF_SOURCE_REGEX, + }, + }, + }, + }); + + const bindings: Binding[] = []; + for (const importNode of rimrafImports) { + bindings.push(...parseImportBindings(importNode.text())); + } + + if (!bindings.length) return null; + + const replacement: ImportReplacement = { + usesGlobSync: false, + usesRm: false, + usesRmPromise: false, + usesRmSync: false, + }; + + for (const binding of bindings) { + const calls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'identifier', + regex: `^${escapeRegex(binding.name)}$`, + }, + }, + }); + + for (const call of calls) { + const args = getCallArguments(call); + const pathArg = args[0]; + if (!pathArg) continue; + + if (binding.kind === 'sync') { + replacement.usesRmSync = true; + + if (isGlobLiteral(pathArg)) { + replacement.usesGlobSync = true; + const loopText = [ + `for (const filePath of globSync(${pathArg})) {`, + `\trmSync(filePath, ${buildRmOptions()});`, + '}', + ].join(lineEnding); + const parent = call.parent(); + edits.push( + parent?.kind() === 'expression_statement' + ? parent.replace(loopText) + : call.replace(loopText), + ); + continue; + } + + edits.push(call.replace(`rmSync(${pathArg}, ${buildRmOptions()})`)); + continue; + } + + const callbackArg = + args.length >= 2 && !isObjectLiteral(args.at(-1)) + ? args.at(-1) + : undefined; + + if (callbackArg) { + replacement.usesRm = true; + edits.push( + call.replace(`rm(${pathArg}, ${buildRmOptions()}, ${callbackArg})`), + ); + continue; + } + + replacement.usesRmPromise = true; + edits.push(call.replace(`rmPromise(${pathArg}, ${buildRmOptions()})`)); + } + } + + if (!edits.length) return null; + + const importReplacement = buildImportReplacement(replacement, lineEnding); + for (const [index, importNode] of rimrafImports.entries()) { + if (index === 0) { + edits.push(importNode.replace(importReplacement)); + } else { + edits.push(importNode.replace('')); + } + } + + return rootNode.commitEdits(edits); +} diff --git a/recipes/rimraf-to-fs-rm/tests/aliased-v5-async/expected.js b/recipes/rimraf-to-fs-rm/tests/aliased-v5-async/expected.js new file mode 100644 index 00000000..2925ae86 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/aliased-v5-async/expected.js @@ -0,0 +1,3 @@ +import { rm as rmPromise } from "node:fs/promises"; + +await rmPromise("coverage", { recursive: true, force: true }); diff --git a/recipes/rimraf-to-fs-rm/tests/aliased-v5-async/input.js b/recipes/rimraf-to-fs-rm/tests/aliased-v5-async/input.js new file mode 100644 index 00000000..202d26d5 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/aliased-v5-async/input.js @@ -0,0 +1,3 @@ +import { rimraf as remove } from "rimraf-v5"; + +await remove("coverage"); diff --git a/recipes/rimraf-to-fs-rm/tests/aliased-v5-sync/expected.js b/recipes/rimraf-to-fs-rm/tests/aliased-v5-sync/expected.js new file mode 100644 index 00000000..ddb615a6 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/aliased-v5-sync/expected.js @@ -0,0 +1,3 @@ +import { rmSync } from "node:fs"; + +rmSync("tmp", { recursive: true, force: true }); diff --git a/recipes/rimraf-to-fs-rm/tests/aliased-v5-sync/input.js b/recipes/rimraf-to-fs-rm/tests/aliased-v5-sync/input.js new file mode 100644 index 00000000..841f7cb2 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/aliased-v5-sync/input.js @@ -0,0 +1,3 @@ +import { rimrafSync as removeSync } from "rimraf-v5"; + +removeSync("tmp"); diff --git a/recipes/rimraf-to-fs-rm/tests/default-v4-async/expected.js b/recipes/rimraf-to-fs-rm/tests/default-v4-async/expected.js new file mode 100644 index 00000000..66371ab0 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/default-v4-async/expected.js @@ -0,0 +1,3 @@ +import { rm as rmPromise } from "node:fs/promises"; + +await rmPromise("dist", { recursive: true, force: true }); diff --git a/recipes/rimraf-to-fs-rm/tests/default-v4-async/input.js b/recipes/rimraf-to-fs-rm/tests/default-v4-async/input.js new file mode 100644 index 00000000..30474e6c --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/default-v4-async/input.js @@ -0,0 +1,3 @@ +import rimraf from "rimraf-v4"; + +await rimraf("dist", { glob: false }); diff --git a/recipes/rimraf-to-fs-rm/tests/ignores-other-packages/expected.js b/recipes/rimraf-to-fs-rm/tests/ignores-other-packages/expected.js new file mode 100644 index 00000000..e2d72f68 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/ignores-other-packages/expected.js @@ -0,0 +1,3 @@ +import notRimraf from "not-rimraf"; + +await notRimraf("dist"); diff --git a/recipes/rimraf-to-fs-rm/tests/ignores-other-packages/input.js b/recipes/rimraf-to-fs-rm/tests/ignores-other-packages/input.js new file mode 100644 index 00000000..e2d72f68 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/ignores-other-packages/input.js @@ -0,0 +1,3 @@ +import notRimraf from "not-rimraf"; + +await notRimraf("dist"); diff --git a/recipes/rimraf-to-fs-rm/tests/mixed-v5-async-sync/expected.js b/recipes/rimraf-to-fs-rm/tests/mixed-v5-async-sync/expected.js new file mode 100644 index 00000000..7fac9d05 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/mixed-v5-async-sync/expected.js @@ -0,0 +1,5 @@ +import { rmSync } from "node:fs"; +import { rm as rmPromise } from "node:fs/promises"; + +await rmPromise("dist", { recursive: true, force: true }); +rmSync("build", { recursive: true, force: true }); diff --git a/recipes/rimraf-to-fs-rm/tests/mixed-v5-async-sync/input.js b/recipes/rimraf-to-fs-rm/tests/mixed-v5-async-sync/input.js new file mode 100644 index 00000000..2b8168cc --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/mixed-v5-async-sync/input.js @@ -0,0 +1,4 @@ +import { rimraf, rimrafSync } from "rimraf-v5"; + +await rimraf("dist"); +rimrafSync("build"); diff --git a/recipes/rimraf-to-fs-rm/tests/remove-dependencies/keep-rimraf-cli/expected.json b/recipes/rimraf-to-fs-rm/tests/remove-dependencies/keep-rimraf-cli/expected.json new file mode 100644 index 00000000..41451b38 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/remove-dependencies/keep-rimraf-cli/expected.json @@ -0,0 +1,11 @@ +{ + "name": "fixture", + "version": "1.0.0", + "scripts": { + "clean": "rimraf dist" + }, + "dependencies": { + "rimraf": "^5.0.0", + "left-pad": "^1.3.0" + } +} diff --git a/recipes/rimraf-to-fs-rm/tests/remove-dependencies/keep-rimraf-cli/input.json b/recipes/rimraf-to-fs-rm/tests/remove-dependencies/keep-rimraf-cli/input.json new file mode 100644 index 00000000..41451b38 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/remove-dependencies/keep-rimraf-cli/input.json @@ -0,0 +1,11 @@ +{ + "name": "fixture", + "version": "1.0.0", + "scripts": { + "clean": "rimraf dist" + }, + "dependencies": { + "rimraf": "^5.0.0", + "left-pad": "^1.3.0" + } +} diff --git a/recipes/rimraf-to-fs-rm/tests/remove-dependencies/remove-rimraf/expected.json b/recipes/rimraf-to-fs-rm/tests/remove-dependencies/remove-rimraf/expected.json new file mode 100644 index 00000000..a2db4793 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/remove-dependencies/remove-rimraf/expected.json @@ -0,0 +1,11 @@ +{ + "name": "fixture", + "version": "1.0.0", + "dependencies": { + "rimraf-v4": "npm:rimraf@^4.4.1", + "left-pad": "^1.3.0" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} diff --git a/recipes/rimraf-to-fs-rm/tests/remove-dependencies/remove-rimraf/input.json b/recipes/rimraf-to-fs-rm/tests/remove-dependencies/remove-rimraf/input.json new file mode 100644 index 00000000..1c9677e9 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/remove-dependencies/remove-rimraf/input.json @@ -0,0 +1,13 @@ +{ + "name": "fixture", + "version": "1.0.0", + "dependencies": { + "rimraf": "^5.0.0", + "rimraf-v4": "npm:rimraf@^4.4.1", + "left-pad": "^1.3.0" + }, + "devDependencies": { + "@types/rimraf": "^3.0.2", + "typescript": "^5.6.0" + } +} diff --git a/recipes/rimraf-to-fs-rm/tests/sync-glob/expected.js b/recipes/rimraf-to-fs-rm/tests/sync-glob/expected.js new file mode 100644 index 00000000..4bc05ffd --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/sync-glob/expected.js @@ -0,0 +1,5 @@ +import { globSync, rmSync } from "node:fs"; + +for (const filePath of globSync("dist/**/*.js")) { + rmSync(filePath, { recursive: true, force: true }); +} diff --git a/recipes/rimraf-to-fs-rm/tests/sync-glob/input.js b/recipes/rimraf-to-fs-rm/tests/sync-glob/input.js new file mode 100644 index 00000000..c49c1e8f --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/sync-glob/input.js @@ -0,0 +1,3 @@ +import { rimrafSync } from "rimraf-v5"; + +rimrafSync("dist/**/*.js"); diff --git a/recipes/rimraf-to-fs-rm/tests/v3-callback/expected.js b/recipes/rimraf-to-fs-rm/tests/v3-callback/expected.js new file mode 100644 index 00000000..bb4a42cc --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/v3-callback/expected.js @@ -0,0 +1,5 @@ +import { rm } from "node:fs"; + +rm("dist", { recursive: true, force: true }, (error) => { + if (error) throw error; +}); diff --git a/recipes/rimraf-to-fs-rm/tests/v3-callback/input.js b/recipes/rimraf-to-fs-rm/tests/v3-callback/input.js new file mode 100644 index 00000000..d24c9e1b --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/v3-callback/input.js @@ -0,0 +1,5 @@ +import rimraf from "rimraf-v3"; + +rimraf("dist", (error) => { + if (error) throw error; +}); diff --git a/recipes/rimraf-to-fs-rm/workflow.yaml b/recipes/rimraf-to-fs-rm/workflow.yaml new file mode 100644 index 00000000..93f38f71 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/workflow.yaml @@ -0,0 +1,42 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: Migrate literal rimraf deletes to Node.js fs.rm APIs + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + + - id: remove-dependencies + name: Remove rimraf dependency + type: automatic + steps: + - name: Remove rimraf dependencies when the CLI is not used + js-ast-grep: + js_file: src/remove-dependencies.ts + base_path: . + include: + - "**/package.json" + exclude: + - "**/node_modules/**" + language: typescript + capabilities: + - child_process + - fs