From 3bd32ca455b2ffd0b127b9f26a8b7a57c0d2acd6 Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Sat, 6 Jun 2026 18:48:31 +0200 Subject: [PATCH 1/8] feat(rimraf): add fs.rm migration recipe --- package-lock.json | 12 + recipes/rimraf-to-fs-rm/README.md | 57 +++++ recipes/rimraf-to-fs-rm/codemod.yaml | 24 ++ recipes/rimraf-to-fs-rm/package.json | 21 ++ recipes/rimraf-to-fs-rm/src/workflow.ts | 242 ++++++++++++++++++ .../rimraf-to-fs-rm/tests/file-1/expected.js | 3 + recipes/rimraf-to-fs-rm/tests/file-1/input.js | 3 + .../rimraf-to-fs-rm/tests/file-2/expected.js | 5 + recipes/rimraf-to-fs-rm/tests/file-2/input.js | 4 + .../rimraf-to-fs-rm/tests/file-3/expected.js | 5 + recipes/rimraf-to-fs-rm/tests/file-3/input.js | 5 + .../rimraf-to-fs-rm/tests/file-4/expected.js | 5 + recipes/rimraf-to-fs-rm/tests/file-4/input.js | 3 + .../rimraf-to-fs-rm/tests/file-5/expected.js | 3 + recipes/rimraf-to-fs-rm/tests/file-5/input.js | 3 + .../rimraf-to-fs-rm/tests/file-6/expected.js | 3 + recipes/rimraf-to-fs-rm/tests/file-6/input.js | 3 + .../rimraf-to-fs-rm/tests/file-7/expected.js | 3 + recipes/rimraf-to-fs-rm/tests/file-7/input.js | 3 + recipes/rimraf-to-fs-rm/workflow.yaml | 25 ++ 20 files changed, 432 insertions(+) create mode 100644 recipes/rimraf-to-fs-rm/README.md create mode 100644 recipes/rimraf-to-fs-rm/codemod.yaml create mode 100644 recipes/rimraf-to-fs-rm/package.json create mode 100644 recipes/rimraf-to-fs-rm/src/workflow.ts create mode 100644 recipes/rimraf-to-fs-rm/tests/file-1/expected.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-1/input.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-2/expected.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-2/input.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-3/expected.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-3/input.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-4/expected.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-4/input.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-5/expected.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-5/input.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-6/expected.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-6/input.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-7/expected.js create mode 100644 recipes/rimraf-to-fs-rm/tests/file-7/input.js create mode 100644 recipes/rimraf-to-fs-rm/workflow.yaml diff --git a/package-lock.json b/package-lock.json index a9f400c6..90e5ae27 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,14 @@ "@codemod.com/jssg-types": "^1.6.1" } }, + "recipes/rimraf-to-fs-rm": { + "name": "@nodejs/rimraf-to-fs-rm", + "version": "1.0.0", + "license": "MIT", + "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..0d0cda8b --- /dev/null +++ b/recipes/rimraf-to-fs-rm/README.md @@ -0,0 +1,57 @@ +# 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()` + +## 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 +- package-specific fallback behavior on Windows + +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..442a8888 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/package.json @@ -0,0 +1,21 @@ +{ + "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": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests" + }, + "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" + } +} 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..7511881c --- /dev/null +++ b/recipes/rimraf-to-fs-rm/src/workflow.ts @@ -0,0 +1,242 @@ +import type { Edit, 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])?$'; + +const escapeRegex = (value: string) => + value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + +const splitTopLevelArguments = (source: string): string[] => { + const args: string[] = []; + let current = ''; + let depth = 0; + let quote: string | null = null; + let escaped = false; + + for (const char of source) { + if (escaped) { + current += char; + escaped = false; + continue; + } + + if (char === '\\') { + current += char; + escaped = true; + continue; + } + + if (quote) { + current += char; + if (char === quote) quote = null; + continue; + } + + if (char === '"' || char === "'" || char === '`') { + current += char; + quote = char; + continue; + } + + if (char === '(' || char === '[' || char === '{') { + depth++; + } else if (char === ')' || char === ']' || char === '}') { + depth--; + } + + if (char === ',' && depth === 0) { + args.push(current.trim()); + current = ''; + continue; + } + + current += char; + } + + if (current.trim()) args.push(current.trim()); + return args; +}; + +const parseCallArguments = (text: string): string[] => { + const openParen = text.indexOf('('); + const closeParen = text.lastIndexOf(')'); + if (openParen === -1 || closeParen === -1 || closeParen <= openParen) { + return []; + } + + return splitTopLevelArguments(text.slice(openParen + 1, closeParen)); +}; + +const isObjectLiteral = (value: string | undefined) => + value?.trim().startsWith('{') ?? false; + +const isGlobLiteral = (value: string | undefined) => { + if (!value) return false; + const trimmed = value.trim(); + if (!/^["'`]/.test(trimmed)) return false; + return /[*?{}[\]]/.test(trimmed.slice(1, -1)); +}; + +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; +}; + +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; +}; + +const buildImportReplacement = (replacement: ImportReplacement) => { + 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('\n'); +}; + +const buildRmOptions = () => '{ recursive: true, force: true }'; + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + 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 = parseCallArguments(call.text()); + 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})) {\n\trmSync(filePath, ${buildRmOptions()});\n}`; + 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); + rimrafImports.forEach((importNode, index) => { + edits.push(importNode.replace(index === 0 ? importReplacement : '')); + }); + + return rootNode.commitEdits(edits); +} diff --git a/recipes/rimraf-to-fs-rm/tests/file-1/expected.js b/recipes/rimraf-to-fs-rm/tests/file-1/expected.js new file mode 100644 index 00000000..66371ab0 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-1/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/file-1/input.js b/recipes/rimraf-to-fs-rm/tests/file-1/input.js new file mode 100644 index 00000000..30474e6c --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-1/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/file-2/expected.js b/recipes/rimraf-to-fs-rm/tests/file-2/expected.js new file mode 100644 index 00000000..7fac9d05 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-2/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/file-2/input.js b/recipes/rimraf-to-fs-rm/tests/file-2/input.js new file mode 100644 index 00000000..2b8168cc --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-2/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/file-3/expected.js b/recipes/rimraf-to-fs-rm/tests/file-3/expected.js new file mode 100644 index 00000000..bb4a42cc --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-3/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/file-3/input.js b/recipes/rimraf-to-fs-rm/tests/file-3/input.js new file mode 100644 index 00000000..d24c9e1b --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-3/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/tests/file-4/expected.js b/recipes/rimraf-to-fs-rm/tests/file-4/expected.js new file mode 100644 index 00000000..4bc05ffd --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-4/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/file-4/input.js b/recipes/rimraf-to-fs-rm/tests/file-4/input.js new file mode 100644 index 00000000..c49c1e8f --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-4/input.js @@ -0,0 +1,3 @@ +import { rimrafSync } from "rimraf-v5"; + +rimrafSync("dist/**/*.js"); diff --git a/recipes/rimraf-to-fs-rm/tests/file-5/expected.js b/recipes/rimraf-to-fs-rm/tests/file-5/expected.js new file mode 100644 index 00000000..2925ae86 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-5/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/file-5/input.js b/recipes/rimraf-to-fs-rm/tests/file-5/input.js new file mode 100644 index 00000000..202d26d5 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-5/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/file-6/expected.js b/recipes/rimraf-to-fs-rm/tests/file-6/expected.js new file mode 100644 index 00000000..ddb615a6 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-6/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/file-6/input.js b/recipes/rimraf-to-fs-rm/tests/file-6/input.js new file mode 100644 index 00000000..841f7cb2 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-6/input.js @@ -0,0 +1,3 @@ +import { rimrafSync as removeSync } from "rimraf-v5"; + +removeSync("tmp"); diff --git a/recipes/rimraf-to-fs-rm/tests/file-7/expected.js b/recipes/rimraf-to-fs-rm/tests/file-7/expected.js new file mode 100644 index 00000000..e2d72f68 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-7/expected.js @@ -0,0 +1,3 @@ +import notRimraf from "not-rimraf"; + +await notRimraf("dist"); diff --git a/recipes/rimraf-to-fs-rm/tests/file-7/input.js b/recipes/rimraf-to-fs-rm/tests/file-7/input.js new file mode 100644 index 00000000..e2d72f68 --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/file-7/input.js @@ -0,0 +1,3 @@ +import notRimraf from "not-rimraf"; + +await notRimraf("dist"); diff --git a/recipes/rimraf-to-fs-rm/workflow.yaml b/recipes/rimraf-to-fs-rm/workflow.yaml new file mode 100644 index 00000000..7de9fa0d --- /dev/null +++ b/recipes/rimraf-to-fs-rm/workflow.yaml @@ -0,0 +1,25 @@ +# 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 From ab6103fff83ba49a4a165f1526f67ea620a2e8f0 Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Sat, 6 Jun 2026 19:08:52 +0200 Subject: [PATCH 2/8] fix(rimraf): address review feedback --- package-lock.json | 3 + recipes/rimraf-to-fs-rm/README.md | 1 + recipes/rimraf-to-fs-rm/package.json | 7 +- .../src/remove-dependencies.ts | 77 +++++++++++++++++++ recipes/rimraf-to-fs-rm/src/workflow.ts | 30 ++++++++ .../{file-5 => aliased-v5-async}/expected.js | 0 .../{file-5 => aliased-v5-async}/input.js | 0 .../{file-6 => aliased-v5-sync}/expected.js | 0 .../{file-6 => aliased-v5-sync}/input.js | 0 .../{file-1 => default-v4-async}/expected.js | 0 .../{file-1 => default-v4-async}/input.js | 0 .../expected.js | 0 .../input.js | 0 .../expected.js | 0 .../{file-2 => mixed-v5-async-sync}/input.js | 0 .../keep-rimraf-cli/expected.json | 11 +++ .../keep-rimraf-cli/input.json | 11 +++ .../remove-rimraf/expected.json | 10 +++ .../remove-rimraf/input.json | 13 ++++ .../tests/{file-4 => sync-glob}/expected.js | 0 .../tests/{file-4 => sync-glob}/input.js | 0 .../tests/{file-3 => v3-callback}/expected.js | 0 .../tests/{file-3 => v3-callback}/input.js | 0 recipes/rimraf-to-fs-rm/workflow.yaml | 17 ++++ 24 files changed, 179 insertions(+), 1 deletion(-) create mode 100644 recipes/rimraf-to-fs-rm/src/remove-dependencies.ts rename recipes/rimraf-to-fs-rm/tests/{file-5 => aliased-v5-async}/expected.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-5 => aliased-v5-async}/input.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-6 => aliased-v5-sync}/expected.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-6 => aliased-v5-sync}/input.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-1 => default-v4-async}/expected.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-1 => default-v4-async}/input.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-7 => ignores-other-packages}/expected.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-7 => ignores-other-packages}/input.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-2 => mixed-v5-async-sync}/expected.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-2 => mixed-v5-async-sync}/input.js (100%) create mode 100644 recipes/rimraf-to-fs-rm/tests/remove-dependencies/keep-rimraf-cli/expected.json create mode 100644 recipes/rimraf-to-fs-rm/tests/remove-dependencies/keep-rimraf-cli/input.json create mode 100644 recipes/rimraf-to-fs-rm/tests/remove-dependencies/remove-rimraf/expected.json create mode 100644 recipes/rimraf-to-fs-rm/tests/remove-dependencies/remove-rimraf/input.json rename recipes/rimraf-to-fs-rm/tests/{file-4 => sync-glob}/expected.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-4 => sync-glob}/input.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-3 => v3-callback}/expected.js (100%) rename recipes/rimraf-to-fs-rm/tests/{file-3 => v3-callback}/input.js (100%) diff --git a/package-lock.json b/package-lock.json index 90e5ae27..33b7f722 100644 --- a/package-lock.json +++ b/package-lock.json @@ -962,6 +962,9 @@ "name": "@nodejs/rimraf-to-fs-rm", "version": "1.0.0", "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, "devDependencies": { "@codemod.com/jssg-types": "^1.6.1" } diff --git a/recipes/rimraf-to-fs-rm/README.md b/recipes/rimraf-to-fs-rm/README.md index 0d0cda8b..9082a684 100644 --- a/recipes/rimraf-to-fs-rm/README.md +++ b/recipes/rimraf-to-fs-rm/README.md @@ -11,6 +11,7 @@ It covers the literal delete cases for `rimraf` v3, v4, and v5 style imports: - 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 --git a/recipes/rimraf-to-fs-rm/package.json b/recipes/rimraf-to-fs-rm/package.json index 442a8888..a37f0992 100644 --- a/recipes/rimraf-to-fs-rm/package.json +++ b/recipes/rimraf-to-fs-rm/package.json @@ -4,7 +4,9 @@ "description": "Migrate literal rimraf deletes to Node.js fs.rm APIs.", "type": "module", "scripts": { - "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests" + "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", @@ -17,5 +19,8 @@ "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..1bf82f8e --- /dev/null +++ b/recipes/rimraf-to-fs-rm/src/remove-dependencies.ts @@ -0,0 +1,77 @@ +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', + 'rimraf-v3', + 'rimraf-v4', + 'rimraf-v5', + '@types/rimraf', +]; + +/** + * Decodes a JSON string node text while keeping invalid values unchanged. + */ +function parseJsonString(value: string): string { + try { + return JSON.parse(value) as string; + } catch { + return value; + } +} + +/** + * 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; + + const scriptPairs = scriptsObject.findAll({ + rule: { kind: 'pair' }, + }); + + return scriptPairs.some((pair) => { + const value = pair.field('value'); + if (!value?.is('string')) return false; + + return /\brimraf(?:\s|$)/.test(parseJsonString(value.text())); + }); +} + +/** + * 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 index 7511881c..98b14425 100644 --- a/recipes/rimraf-to-fs-rm/src/workflow.ts +++ b/recipes/rimraf-to-fs-rm/src/workflow.ts @@ -17,9 +17,15 @@ type ImportReplacement = { 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, '\\$&'); +/** + * Splits a comma-separated argument list without splitting nested expressions. + */ const splitTopLevelArguments = (source: string): string[] => { const args: string[] = []; let current = ''; @@ -71,6 +77,9 @@ const splitTopLevelArguments = (source: string): string[] => { return args; }; +/** + * Parses a call expression text into its top-level arguments. + */ const parseCallArguments = (text: string): string[] => { const openParen = text.indexOf('('); const closeParen = text.lastIndexOf(')'); @@ -81,9 +90,15 @@ const parseCallArguments = (text: string): string[] => { return splitTopLevelArguments(text.slice(openParen + 1, closeParen)); }; +/** + * 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(); @@ -91,6 +106,9 @@ const isGlobLiteral = (value: string | undefined) => { 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(/{([^}]+)}/); @@ -114,6 +132,9 @@ const parseNamedBindings = (source: string): Binding[] => { 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+)/); @@ -126,6 +147,9 @@ const parseImportBindings = (source: string): Binding[] => { return bindings; }; +/** + * Builds the node:fs imports required by the transformed calls. + */ const buildImportReplacement = (replacement: ImportReplacement) => { const fsImports: string[] = []; if (replacement.usesGlobSync) fsImports.push('globSync'); @@ -143,8 +167,14 @@ const buildImportReplacement = (replacement: ImportReplacement) => { return lines.join('\n'); }; +/** + * 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 edits: Edit[] = []; diff --git a/recipes/rimraf-to-fs-rm/tests/file-5/expected.js b/recipes/rimraf-to-fs-rm/tests/aliased-v5-async/expected.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-5/expected.js rename to recipes/rimraf-to-fs-rm/tests/aliased-v5-async/expected.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-5/input.js b/recipes/rimraf-to-fs-rm/tests/aliased-v5-async/input.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-5/input.js rename to recipes/rimraf-to-fs-rm/tests/aliased-v5-async/input.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-6/expected.js b/recipes/rimraf-to-fs-rm/tests/aliased-v5-sync/expected.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-6/expected.js rename to recipes/rimraf-to-fs-rm/tests/aliased-v5-sync/expected.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-6/input.js b/recipes/rimraf-to-fs-rm/tests/aliased-v5-sync/input.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-6/input.js rename to recipes/rimraf-to-fs-rm/tests/aliased-v5-sync/input.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-1/expected.js b/recipes/rimraf-to-fs-rm/tests/default-v4-async/expected.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-1/expected.js rename to recipes/rimraf-to-fs-rm/tests/default-v4-async/expected.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-1/input.js b/recipes/rimraf-to-fs-rm/tests/default-v4-async/input.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-1/input.js rename to recipes/rimraf-to-fs-rm/tests/default-v4-async/input.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-7/expected.js b/recipes/rimraf-to-fs-rm/tests/ignores-other-packages/expected.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-7/expected.js rename to recipes/rimraf-to-fs-rm/tests/ignores-other-packages/expected.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-7/input.js b/recipes/rimraf-to-fs-rm/tests/ignores-other-packages/input.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-7/input.js rename to recipes/rimraf-to-fs-rm/tests/ignores-other-packages/input.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-2/expected.js b/recipes/rimraf-to-fs-rm/tests/mixed-v5-async-sync/expected.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-2/expected.js rename to recipes/rimraf-to-fs-rm/tests/mixed-v5-async-sync/expected.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-2/input.js b/recipes/rimraf-to-fs-rm/tests/mixed-v5-async-sync/input.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-2/input.js rename to recipes/rimraf-to-fs-rm/tests/mixed-v5-async-sync/input.js 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..8592d9ab --- /dev/null +++ b/recipes/rimraf-to-fs-rm/tests/remove-dependencies/remove-rimraf/expected.json @@ -0,0 +1,10 @@ +{ + "name": "fixture", + "version": "1.0.0", + "dependencies": { + "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/file-4/expected.js b/recipes/rimraf-to-fs-rm/tests/sync-glob/expected.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-4/expected.js rename to recipes/rimraf-to-fs-rm/tests/sync-glob/expected.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-4/input.js b/recipes/rimraf-to-fs-rm/tests/sync-glob/input.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-4/input.js rename to recipes/rimraf-to-fs-rm/tests/sync-glob/input.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-3/expected.js b/recipes/rimraf-to-fs-rm/tests/v3-callback/expected.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-3/expected.js rename to recipes/rimraf-to-fs-rm/tests/v3-callback/expected.js diff --git a/recipes/rimraf-to-fs-rm/tests/file-3/input.js b/recipes/rimraf-to-fs-rm/tests/v3-callback/input.js similarity index 100% rename from recipes/rimraf-to-fs-rm/tests/file-3/input.js rename to recipes/rimraf-to-fs-rm/tests/v3-callback/input.js diff --git a/recipes/rimraf-to-fs-rm/workflow.yaml b/recipes/rimraf-to-fs-rm/workflow.yaml index 7de9fa0d..93f38f71 100644 --- a/recipes/rimraf-to-fs-rm/workflow.yaml +++ b/recipes/rimraf-to-fs-rm/workflow.yaml @@ -23,3 +23,20 @@ nodes: 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 From 8d5b1f1a2778539e314debe074fc8f863346b8bc Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Sun, 7 Jun 2026 11:44:23 +0200 Subject: [PATCH 3/8] fix(rimraf): preserve input line endings --- recipes/rimraf-to-fs-rm/src/workflow.ts | 19 +++++++++++++++---- 1 file changed, 15 insertions(+), 4 deletions(-) diff --git a/recipes/rimraf-to-fs-rm/src/workflow.ts b/recipes/rimraf-to-fs-rm/src/workflow.ts index 98b14425..4439156e 100644 --- a/recipes/rimraf-to-fs-rm/src/workflow.ts +++ b/recipes/rimraf-to-fs-rm/src/workflow.ts @@ -23,6 +23,9 @@ const RIMRAF_SOURCE_REGEX = '^rimraf(-v[345])?$'; const escapeRegex = (value: string) => value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +const getLineEnding = (source: string) => + source.includes('\r\n') ? '\r\n' : '\n'; + /** * Splits a comma-separated argument list without splitting nested expressions. */ @@ -150,7 +153,10 @@ const parseImportBindings = (source: string): Binding[] => { /** * Builds the node:fs imports required by the transformed calls. */ -const buildImportReplacement = (replacement: ImportReplacement) => { +const buildImportReplacement = ( + replacement: ImportReplacement, + lineEnding: string, +) => { const fsImports: string[] = []; if (replacement.usesGlobSync) fsImports.push('globSync'); if (replacement.usesRm) fsImports.push('rm'); @@ -164,7 +170,7 @@ const buildImportReplacement = (replacement: ImportReplacement) => { lines.push('import { rm as rmPromise } from "node:fs/promises";'); } - return lines.join('\n'); + return lines.join(lineEnding); }; /** @@ -177,6 +183,7 @@ const buildRmOptions = () => '{ recursive: true, force: true }'; */ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); + const lineEnding = getLineEnding(rootNode.text()); const edits: Edit[] = []; const rimrafImports = rootNode.findAll({ @@ -229,7 +236,11 @@ export default function transform(root: SgRoot): string | null { if (isGlobLiteral(pathArg)) { replacement.usesGlobSync = true; - const loopText = `for (const filePath of globSync(${pathArg})) {\n\trmSync(filePath, ${buildRmOptions()});\n}`; + const loopText = [ + `for (const filePath of globSync(${pathArg})) {`, + `\trmSync(filePath, ${buildRmOptions()});`, + '}', + ].join(lineEnding); const parent = call.parent(); edits.push( parent?.kind() === 'expression_statement' @@ -263,7 +274,7 @@ export default function transform(root: SgRoot): string | null { if (!edits.length) return null; - const importReplacement = buildImportReplacement(replacement); + const importReplacement = buildImportReplacement(replacement, lineEnding); rimrafImports.forEach((importNode, index) => { edits.push(importNode.replace(index === 0 ? importReplacement : '')); }); From 3d00b351cc89bf70d266451335ff14beefbb91bd Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:01:08 +0200 Subject: [PATCH 4/8] fix(rimraf): simplify review follow-up --- recipes/rimraf-to-fs-rm/README.md | 1 + .../src/remove-dependencies.ts | 39 ++++----- recipes/rimraf-to-fs-rm/src/workflow.ts | 86 ++++--------------- .../remove-rimraf/expected.json | 1 + 4 files changed, 35 insertions(+), 92 deletions(-) diff --git a/recipes/rimraf-to-fs-rm/README.md b/recipes/rimraf-to-fs-rm/README.md index 9082a684..9abf1267 100644 --- a/recipes/rimraf-to-fs-rm/README.md +++ b/recipes/rimraf-to-fs-rm/README.md @@ -53,6 +53,7 @@ semantics rather than only source syntax: - custom glob options - async glob deletes with callbacks - package-specific fallback behavior on Windows +- 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/src/remove-dependencies.ts b/recipes/rimraf-to-fs-rm/src/remove-dependencies.ts index 1bf82f8e..12617ffe 100644 --- a/recipes/rimraf-to-fs-rm/src/remove-dependencies.ts +++ b/recipes/rimraf-to-fs-rm/src/remove-dependencies.ts @@ -4,23 +4,9 @@ import removeDependencies from '@nodejs/codemod-utils/remove-dependencies'; const rimrafPackages = [ 'rimraf', - 'rimraf-v3', - 'rimraf-v4', - 'rimraf-v5', '@types/rimraf', ]; -/** - * Decodes a JSON string node text while keeping invalid values unchanged. - */ -function parseJsonString(value: string): string { - try { - return JSON.parse(value) as string; - } catch { - return value; - } -} - /** * Returns whether package.json scripts still call the rimraf CLI. */ @@ -49,16 +35,21 @@ function hasRimrafCliScript(rootNode: SgNode): boolean { const scriptsObject = scriptsPair?.field('value'); if (!scriptsObject) return false; - const scriptPairs = scriptsObject.findAll({ - rule: { kind: 'pair' }, - }); - - return scriptPairs.some((pair) => { - const value = pair.field('value'); - if (!value?.is('string')) return false; - - return /\brimraf(?:\s|$)/.test(parseJsonString(value.text())); - }); + return Boolean( + scriptsObject.find({ + rule: { + kind: 'pair', + has: { + field: 'value', + kind: 'string', + has: { + kind: 'string_content', + regex: '\\brimraf(?:\\s|$)', + }, + }, + }, + }), + ); } /** diff --git a/recipes/rimraf-to-fs-rm/src/workflow.ts b/recipes/rimraf-to-fs-rm/src/workflow.ts index 4439156e..a40d5b64 100644 --- a/recipes/rimraf-to-fs-rm/src/workflow.ts +++ b/recipes/rimraf-to-fs-rm/src/workflow.ts @@ -1,4 +1,4 @@ -import type { Edit, SgRoot } from '@codemod.com/jssg-types/main'; +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'; @@ -27,70 +27,16 @@ const getLineEnding = (source: string) => source.includes('\r\n') ? '\r\n' : '\n'; /** - * Splits a comma-separated argument list without splitting nested expressions. + * Returns the top-level call arguments. */ -const splitTopLevelArguments = (source: string): string[] => { - const args: string[] = []; - let current = ''; - let depth = 0; - let quote: string | null = null; - let escaped = false; - - for (const char of source) { - if (escaped) { - current += char; - escaped = false; - continue; - } - - if (char === '\\') { - current += char; - escaped = true; - continue; - } - - if (quote) { - current += char; - if (char === quote) quote = null; - continue; - } - - if (char === '"' || char === "'" || char === '`') { - current += char; - quote = char; - continue; - } - - if (char === '(' || char === '[' || char === '{') { - depth++; - } else if (char === ')' || char === ']' || char === '}') { - depth--; - } - - if (char === ',' && depth === 0) { - args.push(current.trim()); - current = ''; - continue; - } - - current += char; - } - - if (current.trim()) args.push(current.trim()); - return args; -}; - -/** - * Parses a call expression text into its top-level arguments. - */ -const parseCallArguments = (text: string): string[] => { - const openParen = text.indexOf('('); - const closeParen = text.lastIndexOf(')'); - if (openParen === -1 || closeParen === -1 || closeParen <= openParen) { - return []; - } - - return splitTopLevelArguments(text.slice(openParen + 1, closeParen)); +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()); }; /** @@ -227,7 +173,7 @@ export default function transform(root: SgRoot): string | null { }); for (const call of calls) { - const args = parseCallArguments(call.text()); + const args = getCallArguments(call); const pathArg = args[0]; if (!pathArg) continue; @@ -275,9 +221,13 @@ export default function transform(root: SgRoot): string | null { if (!edits.length) return null; const importReplacement = buildImportReplacement(replacement, lineEnding); - rimrafImports.forEach((importNode, index) => { - edits.push(importNode.replace(index === 0 ? importReplacement : '')); - }); + 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/remove-dependencies/remove-rimraf/expected.json b/recipes/rimraf-to-fs-rm/tests/remove-dependencies/remove-rimraf/expected.json index 8592d9ab..a2db4793 100644 --- 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 @@ -2,6 +2,7 @@ "name": "fixture", "version": "1.0.0", "dependencies": { + "rimraf-v4": "npm:rimraf@^4.4.1", "left-pad": "^1.3.0" }, "devDependencies": { From 79a6d12bf3b637e9d1f4e4dd2040b750d4fec3d1 Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:02:24 +0200 Subject: [PATCH 5/8] fix(rimraf): address final review nits --- recipes/rimraf-to-fs-rm/README.md | 2 +- recipes/rimraf-to-fs-rm/src/workflow.ts | 4 +++- 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/recipes/rimraf-to-fs-rm/README.md b/recipes/rimraf-to-fs-rm/README.md index 9abf1267..e70f2ec2 100644 --- a/recipes/rimraf-to-fs-rm/README.md +++ b/recipes/rimraf-to-fs-rm/README.md @@ -52,7 +52,7 @@ semantics rather than only source syntax: - custom retry behavior - custom glob options - async glob deletes with callbacks -- package-specific fallback behavior on Windows +- 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 diff --git a/recipes/rimraf-to-fs-rm/src/workflow.ts b/recipes/rimraf-to-fs-rm/src/workflow.ts index a40d5b64..88d76f36 100644 --- a/recipes/rimraf-to-fs-rm/src/workflow.ts +++ b/recipes/rimraf-to-fs-rm/src/workflow.ts @@ -218,7 +218,9 @@ export default function transform(root: SgRoot): string | null { } } - if (!edits.length) return null; + if (!edits.length) { + return null; + } const importReplacement = buildImportReplacement(replacement, lineEnding); for (const [index, importNode] of rimrafImports.entries()) { From 1371be91a7ff3e8c5b14efcbd54f83313f080bf4 Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Tue, 9 Jun 2026 15:03:39 +0200 Subject: [PATCH 6/8] fix(rimraf): refresh edit guard --- recipes/rimraf-to-fs-rm/src/workflow.ts | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/recipes/rimraf-to-fs-rm/src/workflow.ts b/recipes/rimraf-to-fs-rm/src/workflow.ts index 88d76f36..8c85417d 100644 --- a/recipes/rimraf-to-fs-rm/src/workflow.ts +++ b/recipes/rimraf-to-fs-rm/src/workflow.ts @@ -218,7 +218,8 @@ export default function transform(root: SgRoot): string | null { } } - if (!edits.length) { + const hasCallEdits = edits.length > 0; + if (!hasCallEdits) { return null; } From 60f7468b0ef83e64d96a8ce559e4468c76b5b8bb Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Thu, 11 Jun 2026 23:18:59 +0200 Subject: [PATCH 7/8] fix: guard empty rimraf edits --- recipes/rimraf-to-fs-rm/src/workflow.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/recipes/rimraf-to-fs-rm/src/workflow.ts b/recipes/rimraf-to-fs-rm/src/workflow.ts index 8c85417d..732f3b8b 100644 --- a/recipes/rimraf-to-fs-rm/src/workflow.ts +++ b/recipes/rimraf-to-fs-rm/src/workflow.ts @@ -232,5 +232,7 @@ export default function transform(root: SgRoot): string | null { } } + if (!edits.length) return null; + return rootNode.commitEdits(edits); } From 862d23680dbc3d73a53b83c9489209a1f4c87550 Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Fri, 12 Jun 2026 11:17:21 +0200 Subject: [PATCH 8/8] fix: simplify rimraf edit guard --- recipes/rimraf-to-fs-rm/src/workflow.ts | 7 +------ 1 file changed, 1 insertion(+), 6 deletions(-) diff --git a/recipes/rimraf-to-fs-rm/src/workflow.ts b/recipes/rimraf-to-fs-rm/src/workflow.ts index 732f3b8b..a40d5b64 100644 --- a/recipes/rimraf-to-fs-rm/src/workflow.ts +++ b/recipes/rimraf-to-fs-rm/src/workflow.ts @@ -218,10 +218,7 @@ export default function transform(root: SgRoot): string | null { } } - const hasCallEdits = edits.length > 0; - if (!hasCallEdits) { - return null; - } + if (!edits.length) return null; const importReplacement = buildImportReplacement(replacement, lineEnding); for (const [index, importNode] of rimrafImports.entries()) { @@ -232,7 +229,5 @@ export default function transform(root: SgRoot): string | null { } } - if (!edits.length) return null; - return rootNode.commitEdits(edits); }