From a7618bf0d6b5ecd2d1352d8e20ca80d79eca032f Mon Sep 17 00:00:00 2001 From: Yu-Hong Shen Date: Mon, 30 Mar 2026 03:42:50 +0800 Subject: [PATCH 1/4] feat(fs-write-coercion): add codemod for DEP0162 fs write coercion Add explicit String() conversion for objects passed as the data parameter to fs.writeFile(), fs.writeFileSync(), fs.appendFile(), fs.appendFileSync(), fs.write(), and their promises variants. Closes nodejs/userland-migrations#412 Co-Authored-By: Claude Opus 4.6 (1M context) --- package-lock.json | 15 ++ recipes/fs-write-coercion/codemod.yaml | 23 +++ recipes/fs-write-coercion/package.json | 24 +++ recipes/fs-write-coercion/src/workflow.ts | 157 ++++++++++++++++++ .../tests/expected/file-02.js | 6 + .../tests/expected/file-03.js | 4 + .../tests/expected/file-04.js | 6 + .../tests/expected/file-05.js | 8 + .../tests/expected/file-06.mjs | 4 + .../tests/expected/file-07.js | 8 + .../tests/expected/file-08.js | 5 + .../tests/expected/file-1.js | 4 + .../fs-write-coercion/tests/input/file-02.js | 6 + .../fs-write-coercion/tests/input/file-03.js | 4 + .../fs-write-coercion/tests/input/file-04.js | 6 + .../fs-write-coercion/tests/input/file-05.js | 8 + .../fs-write-coercion/tests/input/file-06.mjs | 4 + .../fs-write-coercion/tests/input/file-07.js | 8 + .../fs-write-coercion/tests/input/file-08.js | 5 + .../fs-write-coercion/tests/input/file-1.js | 4 + recipes/fs-write-coercion/workflow.yaml | 25 +++ 21 files changed, 334 insertions(+) create mode 100644 recipes/fs-write-coercion/codemod.yaml create mode 100644 recipes/fs-write-coercion/package.json create mode 100644 recipes/fs-write-coercion/src/workflow.ts create mode 100644 recipes/fs-write-coercion/tests/expected/file-02.js create mode 100644 recipes/fs-write-coercion/tests/expected/file-03.js create mode 100644 recipes/fs-write-coercion/tests/expected/file-04.js create mode 100644 recipes/fs-write-coercion/tests/expected/file-05.js create mode 100644 recipes/fs-write-coercion/tests/expected/file-06.mjs create mode 100644 recipes/fs-write-coercion/tests/expected/file-07.js create mode 100644 recipes/fs-write-coercion/tests/expected/file-08.js create mode 100644 recipes/fs-write-coercion/tests/expected/file-1.js create mode 100644 recipes/fs-write-coercion/tests/input/file-02.js create mode 100644 recipes/fs-write-coercion/tests/input/file-03.js create mode 100644 recipes/fs-write-coercion/tests/input/file-04.js create mode 100644 recipes/fs-write-coercion/tests/input/file-05.js create mode 100644 recipes/fs-write-coercion/tests/input/file-06.mjs create mode 100644 recipes/fs-write-coercion/tests/input/file-07.js create mode 100644 recipes/fs-write-coercion/tests/input/file-08.js create mode 100644 recipes/fs-write-coercion/tests/input/file-1.js create mode 100644 recipes/fs-write-coercion/workflow.yaml diff --git a/package-lock.json b/package-lock.json index 45c99671..0b76e677 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.0" } }, + "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/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..37b5eacc --- /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 ./" + }, + "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..8a02f75a --- /dev/null +++ b/recipes/fs-write-coercion/src/workflow.ts @@ -0,0 +1,157 @@ +import { + getNodeImportStatements, + getNodeImportCalls, +} from '@nodejs/codemod-utils/ast-grep/import-statement'; +import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +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 + if (/^['"`]/.test(trimmed)) return true; + + // Template literals + if (trimmed.startsWith('`')) return true; + + // Already has .toString() + if (trimmed.endsWith('.toString()')) return true; + + // Already wrapped in String() + if (trimmed.startsWith('String(')) 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 (not an object) + if (/^\d+$/.test(trimmed)) return true; + + // null or undefined + if (trimmed === 'null' || trimmed === 'undefined') return true; + + return false; +} + +/** + * 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 = [ + ...getNodeRequireCalls(root, 'fs'), + ...getNodeImportStatements(root, 'fs'), + ...getNodeImportCalls(root, 'fs'), + ...getNodeRequireCalls(root, 'fs/promises'), + ...getNodeImportStatements(root, 'fs/promises'), + ...getNodeImportCalls(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: `^${escapeRegex(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; + 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/expected/file-02.js b/recipes/fs-write-coercion/tests/expected/file-02.js new file mode 100644 index 00000000..72069ec8 --- /dev/null +++ b/recipes/fs-write-coercion/tests/expected/file-02.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/expected/file-03.js b/recipes/fs-write-coercion/tests/expected/file-03.js new file mode 100644 index 00000000..cda59a93 --- /dev/null +++ b/recipes/fs-write-coercion/tests/expected/file-03.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/expected/file-04.js b/recipes/fs-write-coercion/tests/expected/file-04.js new file mode 100644 index 00000000..5e61dc86 --- /dev/null +++ b/recipes/fs-write-coercion/tests/expected/file-04.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/expected/file-05.js b/recipes/fs-write-coercion/tests/expected/file-05.js new file mode 100644 index 00000000..8ae6067f --- /dev/null +++ b/recipes/fs-write-coercion/tests/expected/file-05.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/expected/file-06.mjs b/recipes/fs-write-coercion/tests/expected/file-06.mjs new file mode 100644 index 00000000..bf22b91a --- /dev/null +++ b/recipes/fs-write-coercion/tests/expected/file-06.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/expected/file-07.js b/recipes/fs-write-coercion/tests/expected/file-07.js new file mode 100644 index 00000000..4b9975b9 --- /dev/null +++ b/recipes/fs-write-coercion/tests/expected/file-07.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/expected/file-08.js b/recipes/fs-write-coercion/tests/expected/file-08.js new file mode 100644 index 00000000..e9d6a236 --- /dev/null +++ b/recipes/fs-write-coercion/tests/expected/file-08.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/expected/file-1.js b/recipes/fs-write-coercion/tests/expected/file-1.js new file mode 100644 index 00000000..8f0188c3 --- /dev/null +++ b/recipes/fs-write-coercion/tests/expected/file-1.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/input/file-02.js b/recipes/fs-write-coercion/tests/input/file-02.js new file mode 100644 index 00000000..c84639e4 --- /dev/null +++ b/recipes/fs-write-coercion/tests/input/file-02.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/input/file-03.js b/recipes/fs-write-coercion/tests/input/file-03.js new file mode 100644 index 00000000..8771c909 --- /dev/null +++ b/recipes/fs-write-coercion/tests/input/file-03.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/input/file-04.js b/recipes/fs-write-coercion/tests/input/file-04.js new file mode 100644 index 00000000..53dcfb2a --- /dev/null +++ b/recipes/fs-write-coercion/tests/input/file-04.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/input/file-05.js b/recipes/fs-write-coercion/tests/input/file-05.js new file mode 100644 index 00000000..81149127 --- /dev/null +++ b/recipes/fs-write-coercion/tests/input/file-05.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/input/file-06.mjs b/recipes/fs-write-coercion/tests/input/file-06.mjs new file mode 100644 index 00000000..92115160 --- /dev/null +++ b/recipes/fs-write-coercion/tests/input/file-06.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/input/file-07.js b/recipes/fs-write-coercion/tests/input/file-07.js new file mode 100644 index 00000000..4b9975b9 --- /dev/null +++ b/recipes/fs-write-coercion/tests/input/file-07.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/input/file-08.js b/recipes/fs-write-coercion/tests/input/file-08.js new file mode 100644 index 00000000..5d4ad51f --- /dev/null +++ b/recipes/fs-write-coercion/tests/input/file-08.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/input/file-1.js b/recipes/fs-write-coercion/tests/input/file-1.js new file mode 100644 index 00000000..8c667b66 --- /dev/null +++ b/recipes/fs-write-coercion/tests/input/file-1.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..55f8c2d5 --- /dev/null +++ b/recipes/fs-write-coercion/workflow.yaml @@ -0,0 +1,25 @@ +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: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript From ed6d502520d77783fa58e0571bc9eb46a04d79e6 Mon Sep 17 00:00:00 2001 From: Yu-Hong Shen Date: Mon, 30 Mar 2026 04:23:30 +0800 Subject: [PATCH 2/4] fix(fs-write-coercion): improve codebase alignment and fix edge cases MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add missing README.md (required per CONTRIBUTING.md) - Add yaml-language-server schema comment to workflow.yaml - Sort include file patterns alphabetically - Fix isSafeType: remove dead template literal check, tighten String() match - Add isLikelyBufferOverload guard for fs.write() buffer overload - Support float numeric literals in isSafeType - Normalize test file naming (file-02 → file-2) Co-Authored-By: Claude Opus 4.6 (1M context) --- recipes/fs-write-coercion/README.md | 32 ++++++++++++++++++ recipes/fs-write-coercion/src/workflow.ts | 33 ++++++++++++++----- .../tests/expected/{file-02.js => file-2.js} | 0 .../tests/expected/{file-03.js => file-3.js} | 0 .../tests/expected/{file-04.js => file-4.js} | 0 .../tests/expected/{file-05.js => file-5.js} | 0 .../expected/{file-06.mjs => file-6.mjs} | 0 .../tests/expected/{file-07.js => file-7.js} | 0 .../tests/expected/{file-08.js => file-8.js} | 0 .../tests/input/{file-02.js => file-2.js} | 0 .../tests/input/{file-03.js => file-3.js} | 0 .../tests/input/{file-04.js => file-4.js} | 0 .../tests/input/{file-05.js => file-5.js} | 0 .../tests/input/{file-06.mjs => file-6.mjs} | 0 .../tests/input/{file-07.js => file-7.js} | 0 .../tests/input/{file-08.js => file-8.js} | 0 recipes/fs-write-coercion/workflow.yaml | 6 ++-- 17 files changed, 61 insertions(+), 10 deletions(-) create mode 100644 recipes/fs-write-coercion/README.md rename recipes/fs-write-coercion/tests/expected/{file-02.js => file-2.js} (100%) rename recipes/fs-write-coercion/tests/expected/{file-03.js => file-3.js} (100%) rename recipes/fs-write-coercion/tests/expected/{file-04.js => file-4.js} (100%) rename recipes/fs-write-coercion/tests/expected/{file-05.js => file-5.js} (100%) rename recipes/fs-write-coercion/tests/expected/{file-06.mjs => file-6.mjs} (100%) rename recipes/fs-write-coercion/tests/expected/{file-07.js => file-7.js} (100%) rename recipes/fs-write-coercion/tests/expected/{file-08.js => file-8.js} (100%) rename recipes/fs-write-coercion/tests/input/{file-02.js => file-2.js} (100%) rename recipes/fs-write-coercion/tests/input/{file-03.js => file-3.js} (100%) rename recipes/fs-write-coercion/tests/input/{file-04.js => file-4.js} (100%) rename recipes/fs-write-coercion/tests/input/{file-05.js => file-5.js} (100%) rename recipes/fs-write-coercion/tests/input/{file-06.mjs => file-6.mjs} (100%) rename recipes/fs-write-coercion/tests/input/{file-07.js => file-7.js} (100%) rename recipes/fs-write-coercion/tests/input/{file-08.js => file-8.js} (100%) 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/src/workflow.ts b/recipes/fs-write-coercion/src/workflow.ts index 8a02f75a..8abeccf2 100644 --- a/recipes/fs-write-coercion/src/workflow.ts +++ b/recipes/fs-write-coercion/src/workflow.ts @@ -29,17 +29,14 @@ const TARGET_FUNCTIONS = [ function isSafeType(text: string): boolean { const trimmed = text.trim(); - // String literals + // String literals and template literals (', ", `) if (/^['"`]/.test(trimmed)) return true; - // Template literals - if (trimmed.startsWith('`')) return true; - // Already has .toString() if (trimmed.endsWith('.toString()')) return true; - // Already wrapped in String() - if (trimmed.startsWith('String(')) 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; @@ -52,8 +49,8 @@ function isSafeType(text: string): boolean { ) return true; - // Numeric literal (not an object) - if (/^\d+$/.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; @@ -61,6 +58,21 @@ function isSafeType(text: string): boolean { 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. @@ -135,6 +147,11 @@ export default function transform(root: SgRoot): string | null { // 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(); diff --git a/recipes/fs-write-coercion/tests/expected/file-02.js b/recipes/fs-write-coercion/tests/expected/file-2.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-02.js rename to recipes/fs-write-coercion/tests/expected/file-2.js diff --git a/recipes/fs-write-coercion/tests/expected/file-03.js b/recipes/fs-write-coercion/tests/expected/file-3.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-03.js rename to recipes/fs-write-coercion/tests/expected/file-3.js diff --git a/recipes/fs-write-coercion/tests/expected/file-04.js b/recipes/fs-write-coercion/tests/expected/file-4.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-04.js rename to recipes/fs-write-coercion/tests/expected/file-4.js diff --git a/recipes/fs-write-coercion/tests/expected/file-05.js b/recipes/fs-write-coercion/tests/expected/file-5.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-05.js rename to recipes/fs-write-coercion/tests/expected/file-5.js diff --git a/recipes/fs-write-coercion/tests/expected/file-06.mjs b/recipes/fs-write-coercion/tests/expected/file-6.mjs similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-06.mjs rename to recipes/fs-write-coercion/tests/expected/file-6.mjs diff --git a/recipes/fs-write-coercion/tests/expected/file-07.js b/recipes/fs-write-coercion/tests/expected/file-7.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-07.js rename to recipes/fs-write-coercion/tests/expected/file-7.js diff --git a/recipes/fs-write-coercion/tests/expected/file-08.js b/recipes/fs-write-coercion/tests/expected/file-8.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-08.js rename to recipes/fs-write-coercion/tests/expected/file-8.js diff --git a/recipes/fs-write-coercion/tests/input/file-02.js b/recipes/fs-write-coercion/tests/input/file-2.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-02.js rename to recipes/fs-write-coercion/tests/input/file-2.js diff --git a/recipes/fs-write-coercion/tests/input/file-03.js b/recipes/fs-write-coercion/tests/input/file-3.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-03.js rename to recipes/fs-write-coercion/tests/input/file-3.js diff --git a/recipes/fs-write-coercion/tests/input/file-04.js b/recipes/fs-write-coercion/tests/input/file-4.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-04.js rename to recipes/fs-write-coercion/tests/input/file-4.js diff --git a/recipes/fs-write-coercion/tests/input/file-05.js b/recipes/fs-write-coercion/tests/input/file-5.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-05.js rename to recipes/fs-write-coercion/tests/input/file-5.js diff --git a/recipes/fs-write-coercion/tests/input/file-06.mjs b/recipes/fs-write-coercion/tests/input/file-6.mjs similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-06.mjs rename to recipes/fs-write-coercion/tests/input/file-6.mjs diff --git a/recipes/fs-write-coercion/tests/input/file-07.js b/recipes/fs-write-coercion/tests/input/file-7.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-07.js rename to recipes/fs-write-coercion/tests/input/file-7.js diff --git a/recipes/fs-write-coercion/tests/input/file-08.js b/recipes/fs-write-coercion/tests/input/file-8.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-08.js rename to recipes/fs-write-coercion/tests/input/file-8.js diff --git a/recipes/fs-write-coercion/workflow.yaml b/recipes/fs-write-coercion/workflow.yaml index 55f8c2d5..90c4d0ec 100644 --- a/recipes/fs-write-coercion/workflow.yaml +++ b/recipes/fs-write-coercion/workflow.yaml @@ -1,3 +1,5 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + version: "1" nodes: @@ -12,11 +14,11 @@ nodes: js_file: src/workflow.ts base_path: . include: + - "**/*.cjs" + - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" - - "**/*.cjs" - - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" From 50f5d38c3df59bf5c6e0883aa798156854b9e927 Mon Sep 17 00:00:00 2001 From: Yu-Hong Shen Date: Wed, 1 Apr 2026 16:45:48 +0800 Subject: [PATCH 3/4] fix(fs-write-coercion): address reviewer feedback - Migrate tests to single-file fixture structure (input.ts + expected.ts per case) - Use getModuleDependencies utility instead of separate import/require helpers - Remove unnecessary escapeRegex() wrapper for identifier matching Co-Authored-By: Claude Opus 4.6 (1M context) --- recipes/fs-write-coercion/src/workflow.ts | 16 ++++------------ .../expected.js} | 0 .../file-7.js => already-valid-calls/input.js} | 0 .../expected.js} | 0 .../file-4.js => append-file-callback/input.js} | 0 .../file-3.js => append-file-sync/expected.js} | 0 .../file-3.js => append-file-sync/input.js} | 0 .../expected.js} | 0 .../file-8.js => destructured-imports/input.js} | 0 .../expected.mjs} | 0 .../input.mjs} | 0 .../file-5.js => fs-write-callback/expected.js} | 0 .../file-5.js => fs-write-callback/input.js} | 0 .../expected.js} | 0 .../file-2.js => write-file-callback/input.js} | 0 .../file-1.js => write-file-sync/expected.js} | 0 .../file-1.js => write-file-sync/input.js} | 0 17 files changed, 4 insertions(+), 12 deletions(-) rename recipes/fs-write-coercion/tests/{expected/file-7.js => already-valid-calls/expected.js} (100%) rename recipes/fs-write-coercion/tests/{input/file-7.js => already-valid-calls/input.js} (100%) rename recipes/fs-write-coercion/tests/{expected/file-4.js => append-file-callback/expected.js} (100%) rename recipes/fs-write-coercion/tests/{input/file-4.js => append-file-callback/input.js} (100%) rename recipes/fs-write-coercion/tests/{expected/file-3.js => append-file-sync/expected.js} (100%) rename recipes/fs-write-coercion/tests/{input/file-3.js => append-file-sync/input.js} (100%) rename recipes/fs-write-coercion/tests/{expected/file-8.js => destructured-imports/expected.js} (100%) rename recipes/fs-write-coercion/tests/{input/file-8.js => destructured-imports/input.js} (100%) rename recipes/fs-write-coercion/tests/{expected/file-6.mjs => esm-write-file-promises/expected.mjs} (100%) rename recipes/fs-write-coercion/tests/{input/file-6.mjs => esm-write-file-promises/input.mjs} (100%) rename recipes/fs-write-coercion/tests/{expected/file-5.js => fs-write-callback/expected.js} (100%) rename recipes/fs-write-coercion/tests/{input/file-5.js => fs-write-callback/input.js} (100%) rename recipes/fs-write-coercion/tests/{expected/file-2.js => write-file-callback/expected.js} (100%) rename recipes/fs-write-coercion/tests/{input/file-2.js => write-file-callback/input.js} (100%) rename recipes/fs-write-coercion/tests/{expected/file-1.js => write-file-sync/expected.js} (100%) rename recipes/fs-write-coercion/tests/{input/file-1.js => write-file-sync/input.js} (100%) diff --git a/recipes/fs-write-coercion/src/workflow.ts b/recipes/fs-write-coercion/src/workflow.ts index 8abeccf2..0d4532e3 100644 --- a/recipes/fs-write-coercion/src/workflow.ts +++ b/recipes/fs-write-coercion/src/workflow.ts @@ -1,8 +1,4 @@ -import { - getNodeImportStatements, - getNodeImportCalls, -} from '@nodejs/codemod-utils/ast-grep/import-statement'; -import { getNodeRequireCalls } from '@nodejs/codemod-utils/ast-grep/require-call'; +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'; @@ -94,12 +90,8 @@ export default function transform(root: SgRoot): string | null { // Gather fs import/require statements (both 'fs' and 'fs/promises') const stmtNodes = [ - ...getNodeRequireCalls(root, 'fs'), - ...getNodeImportStatements(root, 'fs'), - ...getNodeImportCalls(root, 'fs'), - ...getNodeRequireCalls(root, 'fs/promises'), - ...getNodeImportStatements(root, 'fs/promises'), - ...getNodeImportCalls(root, 'fs/promises'), + ...getModuleDependencies(root, 'fs'), + ...getModuleDependencies(root, 'fs/promises'), ]; if (!stmtNodes.length) return null; @@ -116,7 +108,7 @@ export default function transform(root: SgRoot): string | null { has: { field: 'function', any: [ - { kind: 'identifier', regex: `^${escapeRegex(local)}$` }, + { kind: 'identifier', regex: `^${local}$` }, { kind: 'member_expression', has: { diff --git a/recipes/fs-write-coercion/tests/expected/file-7.js b/recipes/fs-write-coercion/tests/already-valid-calls/expected.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-7.js rename to recipes/fs-write-coercion/tests/already-valid-calls/expected.js diff --git a/recipes/fs-write-coercion/tests/input/file-7.js b/recipes/fs-write-coercion/tests/already-valid-calls/input.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-7.js rename to recipes/fs-write-coercion/tests/already-valid-calls/input.js diff --git a/recipes/fs-write-coercion/tests/expected/file-4.js b/recipes/fs-write-coercion/tests/append-file-callback/expected.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-4.js rename to recipes/fs-write-coercion/tests/append-file-callback/expected.js diff --git a/recipes/fs-write-coercion/tests/input/file-4.js b/recipes/fs-write-coercion/tests/append-file-callback/input.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-4.js rename to recipes/fs-write-coercion/tests/append-file-callback/input.js diff --git a/recipes/fs-write-coercion/tests/expected/file-3.js b/recipes/fs-write-coercion/tests/append-file-sync/expected.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-3.js rename to recipes/fs-write-coercion/tests/append-file-sync/expected.js diff --git a/recipes/fs-write-coercion/tests/input/file-3.js b/recipes/fs-write-coercion/tests/append-file-sync/input.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-3.js rename to recipes/fs-write-coercion/tests/append-file-sync/input.js diff --git a/recipes/fs-write-coercion/tests/expected/file-8.js b/recipes/fs-write-coercion/tests/destructured-imports/expected.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-8.js rename to recipes/fs-write-coercion/tests/destructured-imports/expected.js diff --git a/recipes/fs-write-coercion/tests/input/file-8.js b/recipes/fs-write-coercion/tests/destructured-imports/input.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-8.js rename to recipes/fs-write-coercion/tests/destructured-imports/input.js diff --git a/recipes/fs-write-coercion/tests/expected/file-6.mjs b/recipes/fs-write-coercion/tests/esm-write-file-promises/expected.mjs similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-6.mjs rename to recipes/fs-write-coercion/tests/esm-write-file-promises/expected.mjs diff --git a/recipes/fs-write-coercion/tests/input/file-6.mjs b/recipes/fs-write-coercion/tests/esm-write-file-promises/input.mjs similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-6.mjs rename to recipes/fs-write-coercion/tests/esm-write-file-promises/input.mjs diff --git a/recipes/fs-write-coercion/tests/expected/file-5.js b/recipes/fs-write-coercion/tests/fs-write-callback/expected.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-5.js rename to recipes/fs-write-coercion/tests/fs-write-callback/expected.js diff --git a/recipes/fs-write-coercion/tests/input/file-5.js b/recipes/fs-write-coercion/tests/fs-write-callback/input.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-5.js rename to recipes/fs-write-coercion/tests/fs-write-callback/input.js diff --git a/recipes/fs-write-coercion/tests/expected/file-2.js b/recipes/fs-write-coercion/tests/write-file-callback/expected.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-2.js rename to recipes/fs-write-coercion/tests/write-file-callback/expected.js diff --git a/recipes/fs-write-coercion/tests/input/file-2.js b/recipes/fs-write-coercion/tests/write-file-callback/input.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-2.js rename to recipes/fs-write-coercion/tests/write-file-callback/input.js diff --git a/recipes/fs-write-coercion/tests/expected/file-1.js b/recipes/fs-write-coercion/tests/write-file-sync/expected.js similarity index 100% rename from recipes/fs-write-coercion/tests/expected/file-1.js rename to recipes/fs-write-coercion/tests/write-file-sync/expected.js diff --git a/recipes/fs-write-coercion/tests/input/file-1.js b/recipes/fs-write-coercion/tests/write-file-sync/input.js similarity index 100% rename from recipes/fs-write-coercion/tests/input/file-1.js rename to recipes/fs-write-coercion/tests/write-file-sync/input.js From 34c6008b2d448df094e8d83200cd2dd363b04df1 Mon Sep 17 00:00:00 2001 From: Yu-Hong Shen Date: Wed, 1 Apr 2026 17:04:54 +0800 Subject: [PATCH 4/4] fix(fs-write-coercion): fix test script path in package.json Change test fixture path from ./ to ./tests to match single-file fixture structure. Co-Authored-By: Claude Opus 4.6 (1M context) --- recipes/fs-write-coercion/package.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/recipes/fs-write-coercion/package.json b/recipes/fs-write-coercion/package.json index 37b5eacc..b624814c 100644 --- a/recipes/fs-write-coercion/package.json +++ b/recipes/fs-write-coercion/package.json @@ -4,7 +4,7 @@ "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 ./" + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests" }, "repository": { "type": "git",