diff --git a/package-lock.json b/package-lock.json index f3fd0dd9..4be68bf9 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1515,6 +1515,10 @@ "resolved": "recipes/fs-truncate-fd-deprecation", "link": true }, + "node_modules/@nodejs/fs-write-coercion": { + "resolved": "recipes/fs-write-coercion", + "link": true + }, "node_modules/@nodejs/http-classes-with-new": { "resolved": "recipes/http-classes-with-new", "link": true @@ -4416,6 +4420,17 @@ "@codemod.com/jssg-types": "^1.5.1" } }, + "recipes/fs-write-coercion": { + "name": "@nodejs/fs-write-coercion", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.5.0" + } + }, "recipes/http-classes-with-new": { "name": "@nodejs/http-classes-with-new", "version": "1.0.1", diff --git a/recipes/fs-write-coercion/README.md b/recipes/fs-write-coercion/README.md new file mode 100644 index 00000000..6986f3d4 --- /dev/null +++ b/recipes/fs-write-coercion/README.md @@ -0,0 +1,32 @@ +# DEP0162: Implicit coercion of objects to strings in `fs` write functions + +This recipe adds explicit `String()` conversion for objects passed as the data parameter to `fs` write functions. + +See [DEP0162](https://nodejs.org/api/deprecations.html#DEP0162). + +## Example + +```diff + const fs = require("node:fs"); + + const data = { toString: () => "file content" }; +- fs.writeFileSync("file.txt", data); ++ fs.writeFileSync("file.txt", String(data)); +``` + +## Supported APIs + +- `fs.writeFile` / `fs.writeFileSync` +- `fs.appendFile` / `fs.appendFileSync` +- `fs.write` +- `fsPromises.writeFile` / `fsPromises.appendFile` + +Also handles destructured imports: + +```diff + const { writeFileSync } = require("node:fs"); + + const data = { toString: () => "content" }; +- writeFileSync("file.txt", data); ++ writeFileSync("file.txt", String(data)); +``` diff --git a/recipes/fs-write-coercion/codemod.yaml b/recipes/fs-write-coercion/codemod.yaml new file mode 100644 index 00000000..44473281 --- /dev/null +++ b/recipes/fs-write-coercion/codemod.yaml @@ -0,0 +1,23 @@ +schema_version: "1.0" +name: "@nodejs/fs-write-coercion" +version: "1.0.0" +description: Handle DEP0162 by adding explicit String() conversion for objects passed to fs write functions. +author: Stanley Shen +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - fs + - DEP0162 + +registry: + access: public + visibility: public diff --git a/recipes/fs-write-coercion/package.json b/recipes/fs-write-coercion/package.json new file mode 100644 index 00000000..b624814c --- /dev/null +++ b/recipes/fs-write-coercion/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/fs-write-coercion", + "version": "1.0.0", + "description": "Handle DEP0162 by adding explicit String() conversion for objects passed to fs write functions.", + "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/fs-write-coercion", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Stanley Shen", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/fs-write-coercion/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.5.0" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/fs-write-coercion/src/workflow.ts b/recipes/fs-write-coercion/src/workflow.ts new file mode 100644 index 00000000..0d4532e3 --- /dev/null +++ b/recipes/fs-write-coercion/src/workflow.ts @@ -0,0 +1,166 @@ +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import type { SgRoot, Edit, SgNode } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; + +/** + * fs write functions where the data parameter is the 2nd argument. + */ +const TARGET_FUNCTIONS = [ + { path: '$.writeFile', prop: 'writeFile' }, + { path: '$.writeFileSync', prop: 'writeFileSync' }, + { path: '$.appendFile', prop: 'appendFile' }, + { path: '$.appendFileSync', prop: 'appendFileSync' }, + { path: '$.write', prop: 'write' }, + // promises API + { path: '$.promises.writeFile', prop: 'writeFile' }, + { path: '$.promises.appendFile', prop: 'appendFile' }, +]; + +/** + * Check if a text expression is already a safe type that doesn't need String() wrapping. + * Safe types: string literals, template literals, Buffer/TypedArray expressions, + * already-wrapped String() or .toString() calls. + */ +function isSafeType(text: string): boolean { + const trimmed = text.trim(); + + // String literals and template literals (', ", `) + if (/^['"`]/.test(trimmed)) return true; + + // Already has .toString() + if (trimmed.endsWith('.toString()')) return true; + + // Already wrapped in String() — exact match to avoid false positives like Stringify() + if (/^String\(/.test(trimmed) && trimmed.endsWith(')')) return true; + + // Buffer.from(), Buffer.alloc(), etc. + if (/^Buffer\.\w+\(/.test(trimmed)) return true; + + // new Uint8Array, new Int8Array, etc. + if ( + /^new\s+(Uint8Array|Int8Array|Uint16Array|Int16Array|Uint32Array|Int32Array|Float32Array|Float64Array|DataView)\b/.test( + trimmed, + ) + ) + return true; + + // Numeric literal (integers and floats) + if (/^\d+(\.\d+)?$/.test(trimmed)) return true; + + // null or undefined + if (trimmed === 'null' || trimmed === 'undefined') return true; + + return false; +} + +/** + * fs.write() has two overloaded signatures: + * 1. fs.write(fd, buffer, offset, length, position, callback) — buffer overload (>=4 args typically) + * 2. fs.write(fd, string, position, encoding, callback) — string overload + * + * When called with >= 4 args where the 3rd arg looks like a numeric offset, + * it's likely the buffer overload — skip wrapping to avoid corrupting Buffer data. + */ +function isLikelyBufferOverload(args: readonly { text: () => string }[]): boolean { + if (args.length < 4) return false; + const thirdArg = args[2]!.text().trim(); + // If the 3rd argument is a numeric literal (offset), it's likely the buffer overload + return /^\d+$/.test(thirdArg); +} + +/** + * Transform function that adds explicit String() conversion for objects + * passed as the data parameter to fs write functions. + * + * See DEP0162: https://nodejs.org/api/deprecations.html#DEP0162 + * + * Handles: + * - fs.writeFile(file, data, ...) → fs.writeFile(file, String(data), ...) + * - fs.writeFileSync(file, data, ...) → fs.writeFileSync(file, String(data), ...) + * - fs.appendFile(path, data, ...) → fs.appendFile(path, String(data), ...) + * - fs.appendFileSync(path, data, ...) → fs.appendFileSync(path, String(data), ...) + * - fs.write(fd, data, ...) → fs.write(fd, String(data), ...) + * - fsPromises.writeFile/appendFile + * - Destructured imports: writeFile(path, data) → writeFile(path, String(data)) + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + + // Gather fs import/require statements (both 'fs' and 'fs/promises') + const stmtNodes = [ + ...getModuleDependencies(root, 'fs'), + ...getModuleDependencies(root, 'fs/promises'), + ]; + + if (!stmtNodes.length) return null; + + for (const stmt of stmtNodes) { + for (const target of TARGET_FUNCTIONS) { + const local = resolveBindingPath(stmt, target.path); + if (!local) continue; + + // Find all call expressions for this binding + const calls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + any: [ + { kind: 'identifier', regex: `^${local}$` }, + { + kind: 'member_expression', + has: { + field: 'property', + kind: 'property_identifier', + regex: `^${escapeRegex(target.prop)}$`, + }, + }, + ], + }, + }, + }); + + for (const call of calls) { + // Get the arguments node + const argsNode = call.find({ rule: { kind: 'arguments' } }); + if (!argsNode) continue; + + // Get all direct child nodes that are arguments (skip commas, parens) + const args = argsNode + .children() + .filter( + (child) => + child.kind() !== ',' && + child.kind() !== '(' && + child.kind() !== ')', + ); + + // Data is the 2nd argument (index 1) + if (args.length < 2) continue; + + // For fs.write(), skip the buffer overload: + // fs.write(fd, buffer, offset, length, ...) — wrapping buffer with String() is wrong + if (target.prop === 'write' && isLikelyBufferOverload(args)) continue; + + const dataArg = args[1]!; + const dataText = dataArg.text(); + + // Skip if already a safe type + if (isSafeType(dataText)) continue; + + // Wrap with String() + edits.push(dataArg.replace(`String(${dataText})`)); + } + } + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} + +function escapeRegex(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} diff --git a/recipes/fs-write-coercion/tests/already-valid-calls/expected.js b/recipes/fs-write-coercion/tests/already-valid-calls/expected.js new file mode 100644 index 00000000..4b9975b9 --- /dev/null +++ b/recipes/fs-write-coercion/tests/already-valid-calls/expected.js @@ -0,0 +1,8 @@ +const fs = require("node:fs"); + +// These are all valid and don't need changes +fs.writeFileSync("file1.txt", "string"); +fs.writeFileSync("file2.txt", Buffer.from("buffer")); +fs.writeFileSync("file3.txt", new Uint8Array([1, 2, 3])); +fs.writeFileSync("file4.txt", String(someObj)); +fs.writeFileSync("file5.txt", someObj.toString()); diff --git a/recipes/fs-write-coercion/tests/already-valid-calls/input.js b/recipes/fs-write-coercion/tests/already-valid-calls/input.js new file mode 100644 index 00000000..4b9975b9 --- /dev/null +++ b/recipes/fs-write-coercion/tests/already-valid-calls/input.js @@ -0,0 +1,8 @@ +const fs = require("node:fs"); + +// These are all valid and don't need changes +fs.writeFileSync("file1.txt", "string"); +fs.writeFileSync("file2.txt", Buffer.from("buffer")); +fs.writeFileSync("file3.txt", new Uint8Array([1, 2, 3])); +fs.writeFileSync("file4.txt", String(someObj)); +fs.writeFileSync("file5.txt", someObj.toString()); diff --git a/recipes/fs-write-coercion/tests/append-file-callback/expected.js b/recipes/fs-write-coercion/tests/append-file-callback/expected.js new file mode 100644 index 00000000..5e61dc86 --- /dev/null +++ b/recipes/fs-write-coercion/tests/append-file-callback/expected.js @@ -0,0 +1,6 @@ +const fs = require("node:fs"); + +const content = { toString: () => "more content" }; +fs.appendFile("file.txt", String(content), (err) => { + if (err) throw err; +}); diff --git a/recipes/fs-write-coercion/tests/append-file-callback/input.js b/recipes/fs-write-coercion/tests/append-file-callback/input.js new file mode 100644 index 00000000..53dcfb2a --- /dev/null +++ b/recipes/fs-write-coercion/tests/append-file-callback/input.js @@ -0,0 +1,6 @@ +const fs = require("node:fs"); + +const content = { toString: () => "more content" }; +fs.appendFile("file.txt", content, (err) => { + if (err) throw err; +}); diff --git a/recipes/fs-write-coercion/tests/append-file-sync/expected.js b/recipes/fs-write-coercion/tests/append-file-sync/expected.js new file mode 100644 index 00000000..cda59a93 --- /dev/null +++ b/recipes/fs-write-coercion/tests/append-file-sync/expected.js @@ -0,0 +1,4 @@ +const fs = require("node:fs"); + +const data = { toString: () => "appended data" }; +fs.appendFileSync("file.txt", String(data)); diff --git a/recipes/fs-write-coercion/tests/append-file-sync/input.js b/recipes/fs-write-coercion/tests/append-file-sync/input.js new file mode 100644 index 00000000..8771c909 --- /dev/null +++ b/recipes/fs-write-coercion/tests/append-file-sync/input.js @@ -0,0 +1,4 @@ +const fs = require("node:fs"); + +const data = { toString: () => "appended data" }; +fs.appendFileSync("file.txt", data); diff --git a/recipes/fs-write-coercion/tests/destructured-imports/expected.js b/recipes/fs-write-coercion/tests/destructured-imports/expected.js new file mode 100644 index 00000000..e9d6a236 --- /dev/null +++ b/recipes/fs-write-coercion/tests/destructured-imports/expected.js @@ -0,0 +1,5 @@ +const { writeFileSync, appendFileSync } = require("node:fs"); + +const data = { toString: () => "file content" }; +writeFileSync("file.txt", String(data)); +appendFileSync("log.txt", String(data)); diff --git a/recipes/fs-write-coercion/tests/destructured-imports/input.js b/recipes/fs-write-coercion/tests/destructured-imports/input.js new file mode 100644 index 00000000..5d4ad51f --- /dev/null +++ b/recipes/fs-write-coercion/tests/destructured-imports/input.js @@ -0,0 +1,5 @@ +const { writeFileSync, appendFileSync } = require("node:fs"); + +const data = { toString: () => "file content" }; +writeFileSync("file.txt", data); +appendFileSync("log.txt", data); diff --git a/recipes/fs-write-coercion/tests/esm-write-file-promises/expected.mjs b/recipes/fs-write-coercion/tests/esm-write-file-promises/expected.mjs new file mode 100644 index 00000000..bf22b91a --- /dev/null +++ b/recipes/fs-write-coercion/tests/esm-write-file-promises/expected.mjs @@ -0,0 +1,4 @@ +import { writeFile } from "node:fs/promises"; + +const data = { toString: () => "async content" }; +await writeFile("file.txt", String(data)); diff --git a/recipes/fs-write-coercion/tests/esm-write-file-promises/input.mjs b/recipes/fs-write-coercion/tests/esm-write-file-promises/input.mjs new file mode 100644 index 00000000..92115160 --- /dev/null +++ b/recipes/fs-write-coercion/tests/esm-write-file-promises/input.mjs @@ -0,0 +1,4 @@ +import { writeFile } from "node:fs/promises"; + +const data = { toString: () => "async content" }; +await writeFile("file.txt", data); diff --git a/recipes/fs-write-coercion/tests/fs-write-callback/expected.js b/recipes/fs-write-coercion/tests/fs-write-callback/expected.js new file mode 100644 index 00000000..8ae6067f --- /dev/null +++ b/recipes/fs-write-coercion/tests/fs-write-callback/expected.js @@ -0,0 +1,8 @@ +const fs = require("node:fs"); + +const fd = fs.openSync("file.txt", "w"); +const data = { toString: () => "buffer content" }; +fs.write(fd, String(data), (err) => { + if (err) throw err; + fs.closeSync(fd); +}); diff --git a/recipes/fs-write-coercion/tests/fs-write-callback/input.js b/recipes/fs-write-coercion/tests/fs-write-callback/input.js new file mode 100644 index 00000000..81149127 --- /dev/null +++ b/recipes/fs-write-coercion/tests/fs-write-callback/input.js @@ -0,0 +1,8 @@ +const fs = require("node:fs"); + +const fd = fs.openSync("file.txt", "w"); +const data = { toString: () => "buffer content" }; +fs.write(fd, data, (err) => { + if (err) throw err; + fs.closeSync(fd); +}); diff --git a/recipes/fs-write-coercion/tests/write-file-callback/expected.js b/recipes/fs-write-coercion/tests/write-file-callback/expected.js new file mode 100644 index 00000000..72069ec8 --- /dev/null +++ b/recipes/fs-write-coercion/tests/write-file-callback/expected.js @@ -0,0 +1,6 @@ +const fs = require("node:fs"); + +const obj = { toString: () => "content" }; +fs.writeFile("file.txt", String(obj), (err) => { + if (err) throw err; +}); diff --git a/recipes/fs-write-coercion/tests/write-file-callback/input.js b/recipes/fs-write-coercion/tests/write-file-callback/input.js new file mode 100644 index 00000000..c84639e4 --- /dev/null +++ b/recipes/fs-write-coercion/tests/write-file-callback/input.js @@ -0,0 +1,6 @@ +const fs = require("node:fs"); + +const obj = { toString: () => "content" }; +fs.writeFile("file.txt", obj, (err) => { + if (err) throw err; +}); diff --git a/recipes/fs-write-coercion/tests/write-file-sync/expected.js b/recipes/fs-write-coercion/tests/write-file-sync/expected.js new file mode 100644 index 00000000..8f0188c3 --- /dev/null +++ b/recipes/fs-write-coercion/tests/write-file-sync/expected.js @@ -0,0 +1,4 @@ +const fs = require("node:fs"); + +const data = { toString: () => "file content" }; +fs.writeFileSync("file.txt", String(data)); diff --git a/recipes/fs-write-coercion/tests/write-file-sync/input.js b/recipes/fs-write-coercion/tests/write-file-sync/input.js new file mode 100644 index 00000000..8c667b66 --- /dev/null +++ b/recipes/fs-write-coercion/tests/write-file-sync/input.js @@ -0,0 +1,4 @@ +const fs = require("node:fs"); + +const data = { toString: () => "file content" }; +fs.writeFileSync("file.txt", data); diff --git a/recipes/fs-write-coercion/workflow.yaml b/recipes/fs-write-coercion/workflow.yaml new file mode 100644 index 00000000..90c4d0ec --- /dev/null +++ b/recipes/fs-write-coercion/workflow.yaml @@ -0,0 +1,27 @@ +# 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 + runtime: + type: direct + steps: + - name: Add explicit String() conversion for fs write data parameters (DEP0162) + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.cts" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript