diff --git a/recipes/fs-existssync-valid-args/README.md b/recipes/fs-existssync-valid-args/README.md new file mode 100644 index 00000000..4951d2d6 --- /dev/null +++ b/recipes/fs-existssync-valid-args/README.md @@ -0,0 +1,87 @@ +# fs-existssync-valid-args + +This codemod validates and converts invalid argument types to `fs.existsSync()`. It's useful to migrate code that passes invalid argument types which now causes deprecation warnings or errors. + +## Description + +Starting with Node.js, passing invalid argument types to `fs.existsSync()` triggers a deprecation warning ([DEP0187](https://nodejs.org/api/deprecations.html#dep0187-passing-invalid-argument-types-to-fsexistssync)). The function should only receive `string`, `Buffer`, or `URL` arguments as documented in the [Node.js fs.existsSync() documentation](https://nodejs.org/api/fs.html#fsexistssyncpath). + +This codemod automatically: +- Validates that `fs.existsSync()` receives valid argument types +- Converts invalid argument types to valid ones where possible +- Handles both CommonJS (`require`) and ESM (`import`) syntax +- Adds type checks or conversions to ensure argument validity + +## Examples + +### Case 1: Direct Literal Values + +**Before:** +```javascript +const fs = require("node:fs"); + +const exists = fs.existsSync(123); +``` + +**After:** +```javascript +const fs = require("node:fs"); + +const exists = fs.existsSync(String(123)); +``` + +### Case 2: Variable Arguments + +**Before:** +```javascript +const fs = require("node:fs"); + +function checkFile(path) { + return fs.existsSync(path); +} +``` + +**After:** +```javascript +const fs = require("node:fs"); + +function checkFile(path) { + if (typeof path !== 'string' && !Buffer.isBuffer(path) && !(path instanceof URL)) { + path = String(path); + } + return fs.existsSync(path); +} +``` + +### Case 3: Null Values + +**Before:** +```javascript +const fs = require("node:fs"); + +const fileExists = fs.existsSync(null); +``` + +**After:** +```javascript +const fs = require("node:fs"); + +const fileExists = fs.existsSync(String(null || '')); +``` + +### Case 4: Object Arguments + +**Before:** +```javascript +import { existsSync } from "node:fs"; + +const exists = existsSync({ path: '/some/file' }); +``` + +**After:** +```javascript +import { existsSync } from "node:fs"; + +const exists = existsSync(String({ path: '/some/file' })); +``` + diff --git a/recipes/fs-existssync-valid-args/codemod.yaml b/recipes/fs-existssync-valid-args/codemod.yaml new file mode 100644 index 00000000..531574e8 --- /dev/null +++ b/recipes/fs-existssync-valid-args/codemod.yaml @@ -0,0 +1,18 @@ +schema_version: "1.0" +name: "@nodejs/fs-existssync-valid-args" +version: "1.0.0" +description: Handle DEP0187 via validating and converting invalid argument types to fs.existsSync(). +author: Node.js Contributors +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +registry: + access: public + visibility: public + diff --git a/recipes/fs-existssync-valid-args/package.json b/recipes/fs-existssync-valid-args/package.json new file mode 100644 index 00000000..50dc88ee --- /dev/null +++ b/recipes/fs-existssync-valid-args/package.json @@ -0,0 +1,25 @@ +{ + "name": "@nodejs/fs-existssync-valid-args", + "version": "1.0.0", + "description": "Handle DEP0187 via validating and converting invalid argument types to fs.existsSync().", + "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-existssync-valid-args", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Node.js Contributors", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/fs-existssync-valid-args/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.9" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} + diff --git a/recipes/fs-existssync-valid-args/src/workflow.ts b/recipes/fs-existssync-valid-args/src/workflow.ts new file mode 100644 index 00000000..9a479d65 --- /dev/null +++ b/recipes/fs-existssync-valid-args/src/workflow.ts @@ -0,0 +1,202 @@ +import { getNodeImportStatements } 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"; + +/** + * Transform function that validates and converts invalid argument types to fs.existsSync(). + * This is useful to migrate code that passes invalid argument types which now causes + * deprecation warnings or errors (DEP0187). + * + * Handles: + * 1. Direct literal values (numbers, objects) → wrap with String() + * 2. null values → convert to String(null || '') + * 3. Variables/parameters → add type check before the call + * 4. String, Buffer, or URL arguments → leave as is (already valid) + * + * Works with both CommonJS (require) and ESM (import) syntax. + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + + // Collect all fs import/require statements + const allStatementNodes = [ + ...getNodeImportStatements(root, "fs"), + ...getNodeRequireCalls(root, "fs"), + ]; + + // If any import found don't process the file + if (!allStatementNodes.length) return null; + + for (const statementNode of allStatementNodes) { + // Try to resolve the binding path for fs.existsSync + const bindingPath = resolveBindingPath(statementNode, "fs.existsSync"); + + if (!bindingPath) continue; + + // Find all calls to fs.existsSync + const callNodes = rootNode.findAll({ + rule: { + pattern: `${bindingPath}($ARG)`, + }, + }); + + for (const callNode of callNodes) { + const argNode = callNode.getMatch("ARG"); + if (!argNode) continue; + + const argText = argNode.text(); + const argKind = argNode.kind(); + + // Skip if already valid types or wrapped in String/Buffer/URL constructor + if (isAlreadyValid(argText, argKind)) { + continue; + } + + // Handle different argument types + if (argKind === "null") { + // Case: fs.existsSync(null) → fs.existsSync(String(null || '')) + edits.push(argNode.replace(`String(${argText} || '')`)); + } else if (argKind === "identifier") { + // Case: fs.existsSync(path) → add type check before the call + const edit = addTypeCheckForVariable(callNode, argText); + if (edit) { + edits.push(edit); + } + } else if (LITERAL_OR_EXPRESSION_KINDS.includes(argKind)) { + // Case: fs.existsSync(123) or fs.existsSync({ path: '/file' }) + // → fs.existsSync(String(123)) or fs.existsSync(String({ path: '/file' })) + edits.push(argNode.replace(`String(${argText})`)); + } + } + } + + // Also handle destructured import/require: const { existsSync } = require('fs') + for (const statementNode of allStatementNodes) { + const bindingPath = resolveBindingPath(statementNode, "existsSync"); + + if (!bindingPath) continue; + + // Find all calls to existsSync (destructured) + const callNodes = rootNode.findAll({ + rule: { + pattern: `${bindingPath}($ARG)`, + }, + }); + + for (const callNode of callNodes) { + const argNode = callNode.getMatch("ARG"); + if (!argNode) continue; + + const argText = argNode.text(); + const argKind = argNode.kind(); + + // Skip if already valid types or wrapped + if (isAlreadyValid(argText, argKind)) { + continue; + } + + // Handle different argument types + if (argKind === "null") { + edits.push(argNode.replace(`String(${argText} || '')`)); + } else if (argKind === "identifier") { + const edit = addTypeCheckForVariable(callNode, argText); + if (edit) { + edits.push(edit); + } + } else if (LITERAL_OR_EXPRESSION_KINDS.includes(argKind)) { + edits.push(argNode.replace(`String(${argText})`)); + } + } + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} + +/** + * Check if the argument is already a valid type (string, Buffer, URL) + * or already wrapped in String/Buffer/URL constructor + */ +function isAlreadyValid(argText: string, argKind: string): boolean { + // Check if it's a string literal (already valid) + if (argKind === "string" || argKind === "template_string") { + return true; + } + + // Check if it's a new expression (e.g., new URL(), new Buffer()) + if (argKind === "new_expression") { + return true; + } + + // Check if already wrapped with String() or Buffer methods + // Using regex for more robust matching that handles whitespace + if ( + /^\s*String\s*\(/.test(argText) || + /^\s*Buffer\s*\./.test(argText) || + /Buffer\.isBuffer\s*\(/.test(argText) || + /instanceof\s+URL/.test(argText) + ) { + return true; + } + + return false; +} + +/** + * Node kinds that represent literals or expressions that should be wrapped with String() + */ +const LITERAL_OR_EXPRESSION_KINDS = [ + "number", + "object", + "array", + "true", + "false", + "undefined", + "binary_expression", + "unary_expression", + "call_expression", +]; + +/** + * Add type check for variable arguments + * Wraps the fs.existsSync() call with a type check + */ +function addTypeCheckForVariable(callNode: SgNode, varName: string): Edit | null { + // Find the statement containing the call + let statementNode = callNode.parent(); + + while (statementNode && !isStatement(statementNode.kind())) { + statementNode = statementNode.parent(); + } + + if (!statementNode) return null; + + const statementText = statementNode.text(); + + // Add type check before the statement + const typeCheck = `if (typeof ${varName} !== 'string' && !Buffer.isBuffer(${varName}) && !(${varName} instanceof URL)) {\n ${varName} = String(${varName});\n }\n `; + + const newStatement = typeCheck + statementText; + + return statementNode.replace(newStatement); +} + +/** + * Check if a node kind represents a statement + */ +function isStatement(kind: string): boolean { + return [ + "expression_statement", + "return_statement", + "variable_declaration", + "if_statement", + "for_statement", + "while_statement", + "do_statement", + "switch_statement", + ].includes(kind); +} diff --git a/recipes/fs-existssync-valid-args/tests/expected/case1-literal-number.js b/recipes/fs-existssync-valid-args/tests/expected/case1-literal-number.js new file mode 100644 index 00000000..5d964fb4 --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/expected/case1-literal-number.js @@ -0,0 +1,4 @@ +const fs = require("node:fs"); + +const exists = fs.existsSync(String(123)); + diff --git a/recipes/fs-existssync-valid-args/tests/expected/case2-variable.js b/recipes/fs-existssync-valid-args/tests/expected/case2-variable.js new file mode 100644 index 00000000..7dd96aa5 --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/expected/case2-variable.js @@ -0,0 +1,9 @@ +const fs = require("node:fs"); + +function checkFile(path) { + if (typeof path !== 'string' && !Buffer.isBuffer(path) && !(path instanceof URL)) { + path = String(path); + } + return fs.existsSync(path); +} + diff --git a/recipes/fs-existssync-valid-args/tests/expected/case3-null.js b/recipes/fs-existssync-valid-args/tests/expected/case3-null.js new file mode 100644 index 00000000..5ba331db --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/expected/case3-null.js @@ -0,0 +1,4 @@ +const fs = require("node:fs"); + +const fileExists = fs.existsSync(String(null || '')); + diff --git a/recipes/fs-existssync-valid-args/tests/expected/case4-object.mjs b/recipes/fs-existssync-valid-args/tests/expected/case4-object.mjs new file mode 100644 index 00000000..db77c14e --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/expected/case4-object.mjs @@ -0,0 +1,4 @@ +import { existsSync } from "node:fs"; + +const exists = existsSync(String({ path: '/some/file' })); + diff --git a/recipes/fs-existssync-valid-args/tests/expected/case5-valid-string.js b/recipes/fs-existssync-valid-args/tests/expected/case5-valid-string.js new file mode 100644 index 00000000..4807109d --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/expected/case5-valid-string.js @@ -0,0 +1,7 @@ +const fs = require("node:fs"); + +// These should not be modified as they are already valid +const exists1 = fs.existsSync('/path/to/file'); +const exists2 = fs.existsSync("another/path"); +const exists3 = fs.existsSync(`template/path`); + diff --git a/recipes/fs-existssync-valid-args/tests/expected/case6-esm-namespace.mjs b/recipes/fs-existssync-valid-args/tests/expected/case6-esm-namespace.mjs new file mode 100644 index 00000000..f3382d90 --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/expected/case6-esm-namespace.mjs @@ -0,0 +1,5 @@ +import fs from "node:fs"; + +const exists = fs.existsSync(String(123)); +const exists2 = fs.existsSync(String({ path: '/file' })); + diff --git a/recipes/fs-existssync-valid-args/tests/expected/case7-multiple-calls.js b/recipes/fs-existssync-valid-args/tests/expected/case7-multiple-calls.js new file mode 100644 index 00000000..23d9b6ff --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/expected/case7-multiple-calls.js @@ -0,0 +1,7 @@ +const fs = require("fs"); + +const a = fs.existsSync(String(123)); +const b = fs.existsSync(String(null || '')); +const c = fs.existsSync(String(false)); +const d = fs.existsSync(String([1, 2, 3])); + diff --git a/recipes/fs-existssync-valid-args/tests/expected/case8-mixed-valid-invalid.js b/recipes/fs-existssync-valid-args/tests/expected/case8-mixed-valid-invalid.js new file mode 100644 index 00000000..5f1e98ce --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/expected/case8-mixed-valid-invalid.js @@ -0,0 +1,7 @@ +const { existsSync } = require("node:fs"); + +// Mix of valid and invalid arguments +const valid = existsSync('/valid/path'); +const invalid1 = existsSync(String(456)); +const invalid2 = existsSync(String(undefined)); + diff --git a/recipes/fs-existssync-valid-args/tests/expected/case9-already-wrapped.js b/recipes/fs-existssync-valid-args/tests/expected/case9-already-wrapped.js new file mode 100644 index 00000000..f0e8bf02 --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/expected/case9-already-wrapped.js @@ -0,0 +1,12 @@ +const fs = require("node:fs"); + +// These should NOT be modified - already wrapped with String() +const exists1 = fs.existsSync(String(123)); +const exists2 = fs.existsSync(String(null)); + +// These should NOT be modified - already using Buffer +const exists3 = fs.existsSync(Buffer.from('/path')); + +// These should NOT be modified - already using new URL +const exists4 = fs.existsSync(new URL('file:///path')); + diff --git a/recipes/fs-existssync-valid-args/tests/input/case1-literal-number.js b/recipes/fs-existssync-valid-args/tests/input/case1-literal-number.js new file mode 100644 index 00000000..9242061f --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/input/case1-literal-number.js @@ -0,0 +1,4 @@ +const fs = require("node:fs"); + +const exists = fs.existsSync(123); + diff --git a/recipes/fs-existssync-valid-args/tests/input/case2-variable.js b/recipes/fs-existssync-valid-args/tests/input/case2-variable.js new file mode 100644 index 00000000..f82d1511 --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/input/case2-variable.js @@ -0,0 +1,6 @@ +const fs = require("node:fs"); + +function checkFile(path) { + return fs.existsSync(path); +} + diff --git a/recipes/fs-existssync-valid-args/tests/input/case3-null.js b/recipes/fs-existssync-valid-args/tests/input/case3-null.js new file mode 100644 index 00000000..87cfb8a7 --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/input/case3-null.js @@ -0,0 +1,4 @@ +const fs = require("node:fs"); + +const fileExists = fs.existsSync(null); + diff --git a/recipes/fs-existssync-valid-args/tests/input/case4-object.mjs b/recipes/fs-existssync-valid-args/tests/input/case4-object.mjs new file mode 100644 index 00000000..6fed6c2d --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/input/case4-object.mjs @@ -0,0 +1,4 @@ +import { existsSync } from "node:fs"; + +const exists = existsSync({ path: '/some/file' }); + diff --git a/recipes/fs-existssync-valid-args/tests/input/case5-valid-string.js b/recipes/fs-existssync-valid-args/tests/input/case5-valid-string.js new file mode 100644 index 00000000..4807109d --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/input/case5-valid-string.js @@ -0,0 +1,7 @@ +const fs = require("node:fs"); + +// These should not be modified as they are already valid +const exists1 = fs.existsSync('/path/to/file'); +const exists2 = fs.existsSync("another/path"); +const exists3 = fs.existsSync(`template/path`); + diff --git a/recipes/fs-existssync-valid-args/tests/input/case6-esm-namespace.mjs b/recipes/fs-existssync-valid-args/tests/input/case6-esm-namespace.mjs new file mode 100644 index 00000000..e2550df1 --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/input/case6-esm-namespace.mjs @@ -0,0 +1,5 @@ +import fs from "node:fs"; + +const exists = fs.existsSync(123); +const exists2 = fs.existsSync({ path: '/file' }); + diff --git a/recipes/fs-existssync-valid-args/tests/input/case7-multiple-calls.js b/recipes/fs-existssync-valid-args/tests/input/case7-multiple-calls.js new file mode 100644 index 00000000..c00cfc9e --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/input/case7-multiple-calls.js @@ -0,0 +1,7 @@ +const fs = require("fs"); + +const a = fs.existsSync(123); +const b = fs.existsSync(null); +const c = fs.existsSync(false); +const d = fs.existsSync([1, 2, 3]); + diff --git a/recipes/fs-existssync-valid-args/tests/input/case8-mixed-valid-invalid.js b/recipes/fs-existssync-valid-args/tests/input/case8-mixed-valid-invalid.js new file mode 100644 index 00000000..9181062d --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/input/case8-mixed-valid-invalid.js @@ -0,0 +1,7 @@ +const { existsSync } = require("node:fs"); + +// Mix of valid and invalid arguments +const valid = existsSync('/valid/path'); +const invalid1 = existsSync(456); +const invalid2 = existsSync(undefined); + diff --git a/recipes/fs-existssync-valid-args/tests/input/case9-already-wrapped.js b/recipes/fs-existssync-valid-args/tests/input/case9-already-wrapped.js new file mode 100644 index 00000000..f0e8bf02 --- /dev/null +++ b/recipes/fs-existssync-valid-args/tests/input/case9-already-wrapped.js @@ -0,0 +1,12 @@ +const fs = require("node:fs"); + +// These should NOT be modified - already wrapped with String() +const exists1 = fs.existsSync(String(123)); +const exists2 = fs.existsSync(String(null)); + +// These should NOT be modified - already using Buffer +const exists3 = fs.existsSync(Buffer.from('/path')); + +// These should NOT be modified - already using new URL +const exists4 = fs.existsSync(new URL('file:///path')); + diff --git a/recipes/fs-existssync-valid-args/workflow.yaml b/recipes/fs-existssync-valid-args/workflow.yaml new file mode 100644 index 00000000..a8c6f9e6 --- /dev/null +++ b/recipes/fs-existssync-valid-args/workflow.yaml @@ -0,0 +1,26 @@ +# 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: Handle DEP0187 via validating and converting invalid argument types to fs.existsSync(). + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + diff --git a/utils/src/codemod-jssg-context.ts b/utils/src/codemod-jssg-context.ts index 659d81ee..1f575af8 100644 --- a/utils/src/codemod-jssg-context.ts +++ b/utils/src/codemod-jssg-context.ts @@ -4,9 +4,9 @@ import json from "@ast-grep/lang-json"; import { registerDynamicLanguage } from "@ast-grep/napi"; registerDynamicLanguage({ - // @ts-ignore - https://github.com/ast-grep/langs/tree/main/packages/json#usage + // @ts-expect-error - https://github.com/ast-grep/langs/tree/main/packages/json#usage json, - // @ts-ignore - https://github.com/ast-grep/langs/tree/main/packages/bash#usage + // @ts-expect-error - https://github.com/ast-grep/langs/tree/main/packages/bash#usage bash });