diff --git a/package-lock.json b/package-lock.json index 301b682d..26cb7059 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1448,6 +1448,10 @@ "resolved": "recipes/types-is-native-error", "link": true }, + "node_modules/@nodejs/util-is": { + "resolved": "recipes/util-is", + "link": true + }, "node_modules/@nodejs/util-log-to-console-log": { "resolved": "recipes/util-log-to-console-log", "link": true @@ -4268,6 +4272,17 @@ "@codemod.com/jssg-types": "^1.0.9" } }, + "recipes/util-is": { + "name": "@nodejs/util-is", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "0.0.0" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + } + }, "recipes/util-log-to-console-log": { "name": "@nodejs/util-log-to-console-log", "version": "1.0.0", diff --git a/recipes/util-is/README.md b/recipes/util-is/README.md new file mode 100644 index 00000000..eb0352bc --- /dev/null +++ b/recipes/util-is/README.md @@ -0,0 +1,39 @@ +# `util.is*()` + +This codemod replaces the following deprecated `util.is*()` methods with their modern equivalents: + +- [DEP0044: `util.isArray()`](https://nodejs.org/docs/latest/api/deprecations.html#DEP0044) +- [DEP0045: `util.isBoolean()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0045-utilisboolean) +- [DEP0046: `util.isBuffer()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0046-utilisbuffer) +- [DEP0047: `util.isDate()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0047-utilisdate) +- [DEP0048: `util.isError()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0048-utiliserror) +- [DEP0049: `util.isFunction()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0049-utilisfunction) +- [DEP0050: `util.isNull()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0050-utilisnull) +- [DEP0051: `util.isNullOrUndefined()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0051-utilisnullorundefined) +- [DEP0052: `util.isNumber()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0052-utilisnumber) +- [DEP0053: `util.isObject()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0053-utilisobject) +- [DEP0054: `util.isPrimitive()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0054-utilisprimitive) +- [DEP0055: `util.isRegExp()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0055-utilisregexp) +- [DEP0056: `util.isString()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0056-utilisstring) +- [DEP0057: `util.isSymbol()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0057-utilissymbol) +- [DEP0058: `util.isUndefined()`](https://nodejs.org/docs/latest/api/deprecations.html#dep0058-utilisundefined) + +## Examples + +| **Before** | **After** | +|-----------------------------------|---------------------------------------------| +| `util.isArray(value)` | `Array.isArray(value)` | +| `util.isBoolean(value)` | `typeof value === 'boolean'` | +| `util.isBuffer(value)` | `Buffer.isBuffer(value)` | +| `util.isDate(value)` | `value instanceof Date` | +| `util.isError(value)` | `Error.isError(value)` | +| `util.isFunction(value)` | `typeof value === 'function'` | +| `util.isNull(value)` | `value === null` | +| `util.isNullOrUndefined(value)` | `value === null || value === undefined` | +| `util.isNumber(value)` | `typeof value === 'number'` | +| `util.isObject(value)` | `value && typeof value === 'object'` | +| `util.isPrimitive(value)` | `Object(value) !== value` | +| `util.isRegExp(value)` | `value instanceof RegExp` | +| `util.isString(value)` | `typeof value === 'string'` | +| `util.isSymbol(value)` | `typeof value === 'symbol'` | +| `util.isUndefined(value)` | `typeof value === 'undefined'` | diff --git a/recipes/util-is/codemod.yml b/recipes/util-is/codemod.yml new file mode 100644 index 00000000..b12a055e --- /dev/null +++ b/recipes/util-is/codemod.yml @@ -0,0 +1,21 @@ +schema_version: "1.0" +name: "@nodejs/util-is" +version: 1.0.0 +description: "Replaces deprecated `util.is*()` methods with their modern equivalents." +author: Augustin Mauroy +license: MIT +workflow: workflow.yaml +category: migration + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + +registry: + access: public + visibility: public diff --git a/recipes/util-is/package.json b/recipes/util-is/package.json new file mode 100644 index 00000000..042abd73 --- /dev/null +++ b/recipes/util-is/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/util-is", + "version": "1.0.0", + "description": "Replaces deprecated `util.is*()` methods with their modern equivalents.", + "type": "module", + "scripts": { + "test": "npx codemod@next jssg test -l typescript ./src/workflow.ts ./" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/util-is", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Augustin Mauroy", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/util-is/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.0.3" + }, + "dependencies": { + "@nodejs/codemod-utils": "0.0.0" + } +} diff --git a/recipes/util-is/src/workflow.ts b/recipes/util-is/src/workflow.ts new file mode 100644 index 00000000..c6711f0d --- /dev/null +++ b/recipes/util-is/src/workflow.ts @@ -0,0 +1,369 @@ +import { + getNodeImportStatements, + getDefaultImportIdentifier, +} from "@nodejs/codemod-utils/ast-grep/import-statement"; +import { + getNodeRequireCalls, + getRequireNamespaceIdentifier, +} from "@nodejs/codemod-utils/ast-grep/require-call"; +import { removeBinding } from "@nodejs/codemod-utils/ast-grep/remove-binding"; +import { resolveBindingPath } from "@nodejs/codemod-utils/ast-grep/resolve-binding-path"; +import { removeLines } from "@nodejs/codemod-utils/ast-grep/remove-lines"; +import type { SgRoot, SgNode, Edit, Range } from "@codemod.com/jssg-types/main"; +import type JS from "@codemod.com/jssg-types/langs/javascript"; + +// Clean up unused imports using removeBinding +const allIsMethods = [ + 'isArray', + 'isBoolean', + 'isBuffer', + 'isDate', + 'isError', + 'isFunction', + 'isNull', + 'isNullOrUndefined', + 'isNumber', + 'isObject', + 'isPrimitive', + 'isRegExp', + 'isString', + 'isSymbol', + 'isUndefined' +]; + +// helper to test named import specifiers (kept at module root so it's not re-created per run) +function hasAnyOtherNamedImports(spec: SgNode): boolean { + const firstIdent = spec.find({ rule: { kind: 'identifier' } }); + const name = firstIdent?.text(); + return Boolean(name && allIsMethods.includes(name)); +} + +// Map deprecated util.is*() calls to their modern equivalents +const replacements = new Map string>([ + ['isArray', (arg: string) => `Array.isArray(${arg})`], + ['isBoolean', (arg: string) => `typeof ${arg} === 'boolean'`], + ['isBuffer', (arg: string) => `Buffer.isBuffer(${arg})`], + ['isDate', (arg: string) => `${arg} instanceof Date`], + ['isError', (arg: string) => `Error.isError(${arg})`], + ['isFunction', (arg: string) => `typeof ${arg} === 'function'`], + ['isNull', (arg: string) => `${arg} === null`], + ['isNullOrUndefined', (arg: string) => `${arg} === null || ${arg} === undefined`], + ['isNumber', (arg: string) => `typeof ${arg} === 'number'`], + ['isObject', (arg: string) => `${arg} && typeof ${arg} === 'object'`], + ['isPrimitive', (arg: string) => `Object(${arg}) !== ${arg}`], + ['isRegExp', (arg: string) => `${arg} instanceof RegExp`], + ['isString', (arg: string) => `typeof ${arg} === 'string'`], + ['isSymbol', (arg: string) => `typeof ${arg} === 'symbol'`], + ['isUndefined', (arg: string) => `typeof ${arg} === 'undefined'`], +]); + +/** + * Transform function that converts deprecated util.is*() calls + * to their modern equivalents. + * + * Handles: + * 1. util.isArray() → Array.isArray() + * 2. util.isBoolean() → typeof value === 'boolean' + * 3. util.isBuffer() → Buffer.isBuffer() + * 4. util.isDate() → value instanceof Date + * 5. util.isError() → value instanceof Error + * 6. util.isFunction() → typeof value === 'function' + * 7. util.isNull() → value === null + * 8. util.isNullOrUndefined() → value === null || value === undefined + * 9. util.isNumber() → typeof value === 'number' + * 10. util.isObject() → typeof value === 'object' && value !== null + * 11. util.isPrimitive() → value !== Object(value) + * 12. util.isRegExp() → value instanceof RegExp + * 13. util.isString() → typeof value === 'string' + * 14. util.isSymbol() → typeof value === 'symbol' + * 15. util.isUndefined() → typeof value === 'undefined' + */ +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + const linesToRemove: Range[] = []; + + const usedMethods = new Set(); + const nonIsMethodsUsed = new Set(); + + // Collect util import/require nodes once + const importOrRequireNodes = [ + ...getNodeImportStatements(root, "util"), + ...getNodeRequireCalls(root, "util"), + // local dynamic import variable declarators: const { ... } = await import('node:util') + ...rootNode.findAll({ + rule: { + kind: 'variable_declarator', + all: [ + { has: { field: 'name', any: [{ kind: 'object_pattern' }, { kind: 'identifier' }] } }, + { + has: { + field: 'value', + kind: 'await_expression', + has: { + kind: 'call_expression', + all: [ + { has: { field: 'function', kind: 'import' } }, + { + has: { + field: 'arguments', + kind: 'arguments', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: '(node:)?util$', + }, + }, + }, + }, + ], + }, + }, + }, + ], + }, + }), + ]; + + // Detect namespace/default identifiers to check for non-is usages later + const namespaceBindings = new Set(); + for (const node of importOrRequireNodes) { + // namespace import: import * as ns from 'node:util' + const nsImport = node.find({ + rule: { kind: 'namespace_import' }, + }); + if (nsImport) { + const id = nsImport.find({ rule: { kind: 'identifier' } }); + if (id) namespaceBindings.add(id.text()); + } + + // default import: import util from 'node:util' + const importClause = ( + node.kind() === 'import_statement' + || node.kind() === 'import_clause' + ) + && ( + node.find({ rule: { kind: 'import_clause' } }) + ?? node + ); + + if (importClause) { + const hasNamed = Boolean( + importClause.find({ rule: { kind: 'named_imports' } }) + ); + if (!hasNamed) { + const defaultId = importClause.find({ + rule: { kind: 'identifier', not: { inside: { kind: 'namespace_import' } } }, + }); + if (defaultId) namespaceBindings.add(defaultId.text()); + } + } + + // require namespace: const util = require('node:util') + const reqNs = getRequireNamespaceIdentifier(node); + if (reqNs) namespaceBindings.add(reqNs.text()); + + // dynamic import namespace: const util = await import('node:util') + if (node.kind() === 'variable_declarator') { + const nameField = node.field('name'); + // If not an identifier (i.e., object pattern), skip adding as namespace + const nameIdent = nameField?.kind() === 'identifier' + ? nameField + : node.find({ rule: { kind: 'identifier', inside: { kind: 'variable_declarator' } } }); + + const hasObjectPattern = Boolean( + node.find({ rule: { kind: 'object_pattern' } }) + ); + if (!hasObjectPattern && nameIdent) { + namespaceBindings.add(nameIdent.text()); + } + } + } + + // Mark non-is util usages for any namespace binding discovered + for (const ns of namespaceBindings) { + const usages = rootNode.findAll({ rule: { pattern: `${ns}.$METHOD($$$)` } }); + for (const usage of usages) { + const methodMatch = usage.getMatch('METHOD'); + if (methodMatch) { + const methodName = methodMatch.text(); + if (!replacements.has(methodName)) nonIsMethodsUsed.add(methodName); + } + } + } + + // Resolve local bindings for each util.is* and replace invocations + const localRefsByMethod = new Map>(); + for (const method of replacements.keys()) { + localRefsByMethod.set(method, new Set()); + for (const node of importOrRequireNodes) { + const resolved = resolveBindingPath(node, `$.${method}`); + if (resolved) localRefsByMethod.get(method)!.add(resolved); + } + } + + for (const [method, replacement] of replacements) { + const refs = localRefsByMethod.get(method)!; + for (const ref of refs) { + const calls = rootNode.findAll({ rule: { pattern: `${ref}($ARG)` } }); + + if (!calls.length) continue; + + for (const call of calls) { + const arg = call.getMatch('ARG'); + if (!arg) continue; + const newCallText = replacement(arg.text()); + edits.push(call.replace(newCallText)); + usedMethods.add(method); + } + } + } + + if (!edits.length) return null; + + const importStatements = getNodeImportStatements(root, 'util'); + for (const importNode of importStatements) { + const hasNamespace = Boolean(importNode.find({ rule: { kind: 'namespace_import' } })); + const namedImportSpecifiers = importNode.findAll({ rule: { kind: 'import_specifier' } }); + const hasNamed = namedImportSpecifiers.length > 0; + const defaultIdentifier = getDefaultImportIdentifier(importNode); + + // If all named specifiers are util.is* and there is no default or namespace, drop whole line + if ( + hasNamed && !defaultIdentifier && !hasNamespace && + namedImportSpecifiers.every(spec => hasAnyOtherNamedImports(spec)) + ) { + linesToRemove.push(importNode.range()); + continue; + } + + // Otherwise, remove only named is* bindings; after replacement they are unused + for (const method of allIsMethods) { + const change = removeBinding(importNode, method); + if (change?.edit) edits.push(change.edit); + if (change?.lineToRemove) linesToRemove.push(change.lineToRemove); + } + + // If no other util.is* methods are used, drop default/namespace imports entirely + if (nonIsMethodsUsed.size === 0) { + if ((hasNamespace && !hasNamed) || (defaultIdentifier && !hasNamed)) { + linesToRemove.push(importNode.range()); + } + } + } + + const requireStatements = getNodeRequireCalls(root, 'util'); + for (const requireNode of requireStatements) { + const objectPattern = requireNode.find({ rule: { kind: 'object_pattern' } }); + if (objectPattern) { + const shorthand = objectPattern.findAll({ + rule: { kind: 'shorthand_property_identifier_pattern' } + }); + const pairs = objectPattern.findAll({ rule: { kind: 'pair_pattern' } }); + const importedNames: string[] = []; + for (const s of shorthand) importedNames.push(s.text()); + for (const p of pairs) { + const key = p.find({ rule: { kind: 'property_identifier' } }); + if (key) importedNames.push(key.text()); + } + if (importedNames.length > 0 && importedNames.every((n) => allIsMethods.includes(n))) { + linesToRemove.push(requireNode.range()); + continue; + } + } + + // Otherwise, remove named util.is* bindings; after replacement they are unused + for (const method of allIsMethods) { + const change = removeBinding(requireNode, method); + if (change?.edit) edits.push(change.edit); + if (change?.lineToRemove) linesToRemove.push(change.lineToRemove); + } + + // If no other util.* methods are used, drop namespace requires entirely + if (nonIsMethodsUsed.size === 0) { + const reqNs = getRequireNamespaceIdentifier(requireNode); + const hasObject = Boolean(objectPattern); + if (reqNs && !hasObject) linesToRemove.push(requireNode.range()); + } + } + + // Handle dynamic import variable declarators and import().then chains + const importCallStatements = rootNode.findAll({ + rule: { + kind: 'variable_declarator', + all: [ + { has: { field: 'name', any: [{ kind: 'object_pattern' }, { kind: 'identifier' }] } }, + { + has: { + field: 'value', + kind: 'await_expression', + has: { + kind: 'call_expression', + all: [ + { has: { field: 'function', kind: 'import' } }, + { + has: { + field: 'arguments', + kind: 'arguments', + has: { + kind: 'string', + has: { + kind: 'string_fragment', + regex: '(node:)?util$', + }, + }, + }, + }, + ], + }, + }, + }, + ], + }, + }); + for (const importCall of importCallStatements) { + // Clean up destructured bindings like: const { isArray } = await import('node:util') + const objectPattern = importCall.find({ rule: { kind: 'object_pattern' } }); + if (objectPattern) { + const shorthand = objectPattern.findAll({ + rule: { kind: 'shorthand_property_identifier_pattern' } + }); + const pairs = objectPattern.findAll({ rule: { kind: 'pair_pattern' } }); + const importedNames: string[] = []; + for (const s of shorthand) importedNames.push(s.text()); + for (const p of pairs) { + const key = p.find({ rule: { kind: 'property_identifier' } }); + if (key) importedNames.push(key.text()); + } + if (importedNames.length > 0 && importedNames.every((n) => allIsMethods.includes(n))) { + linesToRemove.push(importCall.range()); + continue; + } + + // Otherwise, remove named util.is* bindings; after replacement they are unused + for (const method of allIsMethods) { + const change = removeBinding(importCall, method); + if (change?.edit) edits.push(change.edit); + if (change?.lineToRemove) linesToRemove.push(change.lineToRemove); + } + } else { + // Namespace dynamic import: const util = await import('node:util') + // If no other util.* methods are used, drop the whole declaration + if (nonIsMethodsUsed.size === 0 && importCall.kind() === 'variable_declarator') { + const nameField = importCall.field('name'); + if (nameField?.kind() === 'identifier') { + linesToRemove.push(importCall.range()); + } + } + } + + // Note: we do not handle import().then chains here to keep scope minimal without utility updates + } + + let sourceCode = rootNode.commitEdits(edits); + // Remove all lines marked for removal (including the whole util require/import if needed) + sourceCode = removeLines(sourceCode, linesToRemove); + + return sourceCode; +} diff --git a/recipes/util-is/tests/expected/file-1.js b/recipes/util-is/tests/expected/file-1.js new file mode 100644 index 00000000..9715d60f --- /dev/null +++ b/recipes/util-is/tests/expected/file-1.js @@ -0,0 +1,46 @@ + +if (Array.isArray(someValue)) { + console.log('someValue is an array'); +} +if (typeof someValue === 'boolean') { + console.log('someValue is a boolean'); +} +if (Buffer.isBuffer(someValue)) { + console.log('someValue is a buffer'); +} +if (someValue instanceof Date) { + console.log('someValue is a date'); +} +if (Error.isError(someValue)) { + console.log('someValue is an error'); +} +if (typeof someValue === 'function') { + console.log('someValue is a function'); +} +if (someValue === null) { + console.log('someValue is null'); +} +if (someValue === null || someValue === undefined) { + console.log('someValue is null or undefined'); +} +if (typeof someValue === 'number') { + console.log('someValue is a number'); +} +if (someValue && typeof someValue === 'object') { + console.log('someValue is an object'); +} +if (Object(someValue) !== someValue) { + console.log('someValue is a primitive'); +} +if (someValue instanceof RegExp) { + console.log('someValue is a regular expression'); +} +if (typeof someValue === 'string') { + console.log('someValue is a string'); +} +if (typeof someValue === 'symbol') { + console.log('someValue is a symbol'); +} +if (typeof someValue === 'undefined') { + console.log('someValue is undefined'); +} diff --git a/recipes/util-is/tests/expected/file-10.mjs b/recipes/util-is/tests/expected/file-10.mjs new file mode 100644 index 00000000..a25bd9de --- /dev/null +++ b/recipes/util-is/tests/expected/file-10.mjs @@ -0,0 +1,7 @@ + +if (Array.isArray(someValue)) { + console.log('someValue is an array'); +} +if (typeof someValue === 'string') { + console.log('someValue is a string'); +} diff --git a/recipes/util-is/tests/expected/file-11.js b/recipes/util-is/tests/expected/file-11.js new file mode 100644 index 00000000..c029469a --- /dev/null +++ b/recipes/util-is/tests/expected/file-11.js @@ -0,0 +1,7 @@ + +if (someValue === null) { + console.log('someValue is null'); +} +if (typeof someValue === 'undefined') { + console.log('someValue is undefined'); +} diff --git a/recipes/util-is/tests/expected/file-12.mjs b/recipes/util-is/tests/expected/file-12.mjs new file mode 100644 index 00000000..f20f1ec2 --- /dev/null +++ b/recipes/util-is/tests/expected/file-12.mjs @@ -0,0 +1,7 @@ +// dynamic import with await destructuring +const { promisify } = await import('node:util'); + +if (Array.isArray(someValue)) { + console.log('someValue is an array'); +} +const p = promisify(setTimeout); diff --git a/recipes/util-is/tests/expected/file-13.mjs b/recipes/util-is/tests/expected/file-13.mjs new file mode 100644 index 00000000..86c7251d --- /dev/null +++ b/recipes/util-is/tests/expected/file-13.mjs @@ -0,0 +1,7 @@ +// dynamic import with namespace assignment +const util = await import('node:util'); + +if (typeof someValue === 'string') { + console.log('someValue is a string'); +} +const p = util.promisify(setTimeout); diff --git a/recipes/util-is/tests/expected/file-2.js b/recipes/util-is/tests/expected/file-2.js new file mode 100644 index 00000000..afe19c50 --- /dev/null +++ b/recipes/util-is/tests/expected/file-2.js @@ -0,0 +1,7 @@ + +if (Array.isArray(someValue)) { + console.log('someValue is an array'); +} +if (typeof someValue === 'boolean') { + console.log('someValue is a boolean'); +} diff --git a/recipes/util-is/tests/expected/file-3.js b/recipes/util-is/tests/expected/file-3.js new file mode 100644 index 00000000..e4376b49 --- /dev/null +++ b/recipes/util-is/tests/expected/file-3.js @@ -0,0 +1,4 @@ + +if (typeof someValue === 'boolean') { + console.log('someValue is a boolean'); +} diff --git a/recipes/util-is/tests/expected/file-4.mjs b/recipes/util-is/tests/expected/file-4.mjs new file mode 100644 index 00000000..afe19c50 --- /dev/null +++ b/recipes/util-is/tests/expected/file-4.mjs @@ -0,0 +1,7 @@ + +if (Array.isArray(someValue)) { + console.log('someValue is an array'); +} +if (typeof someValue === 'boolean') { + console.log('someValue is a boolean'); +} diff --git a/recipes/util-is/tests/expected/file-5.mjs b/recipes/util-is/tests/expected/file-5.mjs new file mode 100644 index 00000000..afe19c50 --- /dev/null +++ b/recipes/util-is/tests/expected/file-5.mjs @@ -0,0 +1,7 @@ + +if (Array.isArray(someValue)) { + console.log('someValue is an array'); +} +if (typeof someValue === 'boolean') { + console.log('someValue is a boolean'); +} diff --git a/recipes/util-is/tests/expected/file-6.js b/recipes/util-is/tests/expected/file-6.js new file mode 100644 index 00000000..0a6a19e6 --- /dev/null +++ b/recipes/util-is/tests/expected/file-6.js @@ -0,0 +1,6 @@ +const { promisify } = require('node:util'); + +if (Array.isArray(someValue)) { + console.log('someValue is an array'); +} +const p = promisify(setTimeout); diff --git a/recipes/util-is/tests/expected/file-7.js b/recipes/util-is/tests/expected/file-7.js new file mode 100644 index 00000000..cad4b0ec --- /dev/null +++ b/recipes/util-is/tests/expected/file-7.js @@ -0,0 +1,6 @@ +const util = require('node:util'); + +if (typeof someValue === 'string') { + console.log('someValue is a string'); +} +const p = util.promisify(setTimeout); diff --git a/recipes/util-is/tests/expected/file-8.mjs b/recipes/util-is/tests/expected/file-8.mjs new file mode 100644 index 00000000..c7d2dcea --- /dev/null +++ b/recipes/util-is/tests/expected/file-8.mjs @@ -0,0 +1,6 @@ +import util from 'node:util'; + +if (typeof someValue === 'function') { + console.log('someValue is a function'); +} +const p = util.promisify(setTimeout); diff --git a/recipes/util-is/tests/expected/file-9.mjs b/recipes/util-is/tests/expected/file-9.mjs new file mode 100644 index 00000000..6f9a55f1 --- /dev/null +++ b/recipes/util-is/tests/expected/file-9.mjs @@ -0,0 +1,6 @@ +import { callbackify } from 'node:util'; + +if (someValue instanceof RegExp) { + console.log('someValue is a regexp'); +} +callbackify(() => { }); diff --git a/recipes/util-is/tests/input/file-1.js b/recipes/util-is/tests/input/file-1.js new file mode 100644 index 00000000..c1f92a11 --- /dev/null +++ b/recipes/util-is/tests/input/file-1.js @@ -0,0 +1,47 @@ +const util = require('node:util'); + +if (util.isArray(someValue)) { + console.log('someValue is an array'); +} +if (util.isBoolean(someValue)) { + console.log('someValue is a boolean'); +} +if (util.isBuffer(someValue)) { + console.log('someValue is a buffer'); +} +if (util.isDate(someValue)) { + console.log('someValue is a date'); +} +if (util.isError(someValue)) { + console.log('someValue is an error'); +} +if (util.isFunction(someValue)) { + console.log('someValue is a function'); +} +if (util.isNull(someValue)) { + console.log('someValue is null'); +} +if (util.isNullOrUndefined(someValue)) { + console.log('someValue is null or undefined'); +} +if (util.isNumber(someValue)) { + console.log('someValue is a number'); +} +if (util.isObject(someValue)) { + console.log('someValue is an object'); +} +if (util.isPrimitive(someValue)) { + console.log('someValue is a primitive'); +} +if (util.isRegExp(someValue)) { + console.log('someValue is a regular expression'); +} +if (util.isString(someValue)) { + console.log('someValue is a string'); +} +if (util.isSymbol(someValue)) { + console.log('someValue is a symbol'); +} +if (util.isUndefined(someValue)) { + console.log('someValue is undefined'); +} diff --git a/recipes/util-is/tests/input/file-10.mjs b/recipes/util-is/tests/input/file-10.mjs new file mode 100644 index 00000000..ea7a0fff --- /dev/null +++ b/recipes/util-is/tests/input/file-10.mjs @@ -0,0 +1,8 @@ +import { isArray as arrayCheck, isString as str } from 'node:util'; + +if (arrayCheck(someValue)) { + console.log('someValue is an array'); +} +if (str(someValue)) { + console.log('someValue is a string'); +} diff --git a/recipes/util-is/tests/input/file-11.js b/recipes/util-is/tests/input/file-11.js new file mode 100644 index 00000000..b69a9dbb --- /dev/null +++ b/recipes/util-is/tests/input/file-11.js @@ -0,0 +1,8 @@ +const { isNull: nil, isUndefined: und } = require('node:util'); + +if (nil(someValue)) { + console.log('someValue is null'); +} +if (und(someValue)) { + console.log('someValue is undefined'); +} diff --git a/recipes/util-is/tests/input/file-12.mjs b/recipes/util-is/tests/input/file-12.mjs new file mode 100644 index 00000000..e0e76225 --- /dev/null +++ b/recipes/util-is/tests/input/file-12.mjs @@ -0,0 +1,7 @@ +// dynamic import with await destructuring +const { isArray, promisify } = await import('node:util'); + +if (isArray(someValue)) { + console.log('someValue is an array'); +} +const p = promisify(setTimeout); diff --git a/recipes/util-is/tests/input/file-13.mjs b/recipes/util-is/tests/input/file-13.mjs new file mode 100644 index 00000000..eae720f4 --- /dev/null +++ b/recipes/util-is/tests/input/file-13.mjs @@ -0,0 +1,7 @@ +// dynamic import with namespace assignment +const util = await import('node:util'); + +if (util.isString(someValue)) { + console.log('someValue is a string'); +} +const p = util.promisify(setTimeout); diff --git a/recipes/util-is/tests/input/file-2.js b/recipes/util-is/tests/input/file-2.js new file mode 100644 index 00000000..d327aac1 --- /dev/null +++ b/recipes/util-is/tests/input/file-2.js @@ -0,0 +1,8 @@ +const { isArray, isBoolean } = require('node:util'); + +if (isArray(someValue)) { + console.log('someValue is an array'); +} +if (isBoolean(someValue)) { + console.log('someValue is a boolean'); +} diff --git a/recipes/util-is/tests/input/file-3.js b/recipes/util-is/tests/input/file-3.js new file mode 100644 index 00000000..23cea17f --- /dev/null +++ b/recipes/util-is/tests/input/file-3.js @@ -0,0 +1,5 @@ +const { isBoolean: isBooleanChecker } = require('node:util'); + +if (isBooleanChecker(someValue)) { + console.log('someValue is a boolean'); +} diff --git a/recipes/util-is/tests/input/file-4.mjs b/recipes/util-is/tests/input/file-4.mjs new file mode 100644 index 00000000..e7217b86 --- /dev/null +++ b/recipes/util-is/tests/input/file-4.mjs @@ -0,0 +1,8 @@ +import util from 'node:util'; + +if (util.isArray(someValue)) { + console.log('someValue is an array'); +} +if (util.isBoolean(someValue)) { + console.log('someValue is a boolean'); +} diff --git a/recipes/util-is/tests/input/file-5.mjs b/recipes/util-is/tests/input/file-5.mjs new file mode 100644 index 00000000..5b3e50a3 --- /dev/null +++ b/recipes/util-is/tests/input/file-5.mjs @@ -0,0 +1,8 @@ +import { isArray, isBoolean } from 'node:util'; + +if (isArray(someValue)) { + console.log('someValue is an array'); +} +if (isBoolean(someValue)) { + console.log('someValue is a boolean'); +} diff --git a/recipes/util-is/tests/input/file-6.js b/recipes/util-is/tests/input/file-6.js new file mode 100644 index 00000000..20ea2372 --- /dev/null +++ b/recipes/util-is/tests/input/file-6.js @@ -0,0 +1,6 @@ +const { isArray, promisify } = require('node:util'); + +if (isArray(someValue)) { + console.log('someValue is an array'); +} +const p = promisify(setTimeout); diff --git a/recipes/util-is/tests/input/file-7.js b/recipes/util-is/tests/input/file-7.js new file mode 100644 index 00000000..9f8e86be --- /dev/null +++ b/recipes/util-is/tests/input/file-7.js @@ -0,0 +1,6 @@ +const util = require('node:util'); + +if (util.isString(someValue)) { + console.log('someValue is a string'); +} +const p = util.promisify(setTimeout); diff --git a/recipes/util-is/tests/input/file-8.mjs b/recipes/util-is/tests/input/file-8.mjs new file mode 100644 index 00000000..fa928820 --- /dev/null +++ b/recipes/util-is/tests/input/file-8.mjs @@ -0,0 +1,6 @@ +import util from 'node:util'; + +if (util.isFunction(someValue)) { + console.log('someValue is a function'); +} +const p = util.promisify(setTimeout); diff --git a/recipes/util-is/tests/input/file-9.mjs b/recipes/util-is/tests/input/file-9.mjs new file mode 100644 index 00000000..99618515 --- /dev/null +++ b/recipes/util-is/tests/input/file-9.mjs @@ -0,0 +1,6 @@ +import { isRegExp, callbackify } from 'node:util'; + +if (isRegExp(someValue)) { + console.log('someValue is a regexp'); +} +callbackify(() => { }); diff --git a/recipes/util-is/tsconfig.json b/recipes/util-is/tsconfig.json new file mode 100644 index 00000000..efbc996f --- /dev/null +++ b/recipes/util-is/tsconfig.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "allowImportingTsExtensions": true, + "allowJs": true, + "alwaysStrict": true, + "baseUrl": "./", + "declaration": true, + "declarationMap": true, + "emitDeclarationOnly": true, + "lib": ["ESNext", "DOM"], + "module": "NodeNext", + "moduleResolution": "NodeNext", + "noImplicitThis": true, + "removeComments": true, + "strict": true, + "stripInternal": true, + "target": "esnext" + }, + "include": ["./"], + "exclude": [ + "tests/**" + ] +} diff --git a/recipes/util-is/workflow.yml b/recipes/util-is/workflow.yml new file mode 100644 index 00000000..7f307e27 --- /dev/null +++ b/recipes/util-is/workflow.yml @@ -0,0 +1,25 @@ +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: "Replaces deprecated `util.is*()` methods with their modern equivalents." + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript diff --git a/utils/src/ast-grep/import-statement.test.ts b/utils/src/ast-grep/import-statement.test.ts index ee99128c..97a207dd 100644 --- a/utils/src/ast-grep/import-statement.test.ts +++ b/utils/src/ast-grep/import-statement.test.ts @@ -5,6 +5,7 @@ import dedent from 'dedent'; import { getNodeImportStatements, getNodeImportCalls, + getDefaultImportIdentifier } from './import-statement.ts'; describe('import-statement', () => { @@ -212,4 +213,53 @@ describe('import-statement', () => { "functions that aren't import shouldn't be caught", ); }); + + it("should handle getDefaultImportIdentifier", () => { + const code = dedent` + import fs from 'fs'; + import { join } from 'node:path'; + import defaultExport from "module-a"; + import * as namespace from "module-b"; + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + const fsImports = getNodeImportStatements(ast, 'fs'); + const fsDefault = getDefaultImportIdentifier(fsImports[0]); + assert.strictEqual(fsDefault?.text(), 'fs'); + + const pathImports = getNodeImportStatements(ast, 'path'); + const pathDefault = getDefaultImportIdentifier(pathImports[0]); + assert.strictEqual(pathDefault, null); + + const moduleAImports = getNodeImportStatements(ast, 'module-a'); + const moduleADefault = getDefaultImportIdentifier(moduleAImports[0]); + assert.strictEqual(moduleADefault?.text(), 'defaultExport'); + + const moduleBImports = getNodeImportStatements(ast, 'module-b'); + const moduleBDefault = getDefaultImportIdentifier(moduleBImports[0]); + assert.strictEqual(moduleBDefault, null); + }); + + it("should handle edge cases for import statements", () => { + const code = dedent` + import "side-effect-only"; + import {} from "empty-imports"; + import fs from 'fs'; + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + // Test modules that don't exist + const nonExistentImports = getNodeImportStatements(ast, 'non-existent'); + assert.strictEqual(nonExistentImports.length, 0); + + // Test side-effect only imports + const sideEffectImports = getNodeImportStatements(ast, 'side-effect-only'); + assert.strictEqual(sideEffectImports.length, 1); + assert.strictEqual(getDefaultImportIdentifier(sideEffectImports[0]), null); + + // Test empty imports + const emptyImports = getNodeImportStatements(ast, 'empty-imports'); + assert.strictEqual(emptyImports.length, 1); + assert.strictEqual(getDefaultImportIdentifier(emptyImports[0]), null); + }); }); diff --git a/utils/src/ast-grep/import-statement.ts b/utils/src/ast-grep/import-statement.ts index e40e34d7..2592792e 100644 --- a/utils/src/ast-grep/import-statement.ts +++ b/utils/src/ast-grep/import-statement.ts @@ -161,3 +161,21 @@ export const getNodeImportCalls = ( return nodes; }; + +/** + * Get the default import identifier from an import statement + */ +export const getDefaultImportIdentifier = (importNode: SgNode): SgNode | null => + importNode.find({ + rule: { + kind: "identifier", + inside: { + kind: "import_clause", + not: { + has: { + kind: "named_imports" + } + } + } + } + }); diff --git a/utils/src/ast-grep/require-call.test.ts b/utils/src/ast-grep/require-call.test.ts index a4f3e30c..76d392f0 100644 --- a/utils/src/ast-grep/require-call.test.ts +++ b/utils/src/ast-grep/require-call.test.ts @@ -2,7 +2,10 @@ import assert from "node:assert/strict"; import { describe, it } from "node:test"; import astGrep from '@ast-grep/napi'; import dedent from 'dedent'; -import { getNodeRequireCalls } from "./require-call.ts"; +import { + getNodeRequireCalls, + getRequireNamespaceIdentifier +} from "./require-call.ts"; describe("require-call", () => { const code = dedent` @@ -39,4 +42,91 @@ describe("require-call", () => { assert.strictEqual(osRequires.length, 1); assert.strictEqual(osRequires[0].field('value')?.text(), 'require("node:os").cpus'); }); + + it("should return require calls", () => { + const fsRequires = getNodeRequireCalls(ast, 'fs'); + assert.strictEqual(fsRequires.length, 1); + assert.strictEqual(fsRequires[0].field('value')?.text(), "require('fs')"); + + const pathRequires = getNodeRequireCalls(ast, 'path'); + assert.strictEqual(pathRequires.length, 1); + assert.strictEqual(pathRequires[0].field('value')?.text(), "require('node:path')"); + + const childProcessRequires = getNodeRequireCalls(ast, 'child_process'); + assert.strictEqual(childProcessRequires.length, 1); + assert.strictEqual(childProcessRequires[0].field('value')?.text(), 'require("child_process")'); + + const utilRequires = getNodeRequireCalls(ast, 'util'); + assert.strictEqual(utilRequires.length, 1); + assert.strictEqual(utilRequires[0].field('value')?.text(), 'require("node:util")'); + }); + + it("should handle getRequireNamespaceIdentifier", () => { + const code = dedent` + const fs = require('fs'); + const { join } = require('node:path'); + const util = require('node:util'); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + const fsRequires = getNodeRequireCalls(ast, 'fs'); + const fsNamespace = getRequireNamespaceIdentifier(fsRequires[0]); + assert.strictEqual(fsNamespace?.text(), 'fs'); + + const pathRequires = getNodeRequireCalls(ast, 'path'); + const pathNamespace = getRequireNamespaceIdentifier(pathRequires[0]); + assert.strictEqual(pathNamespace, null); + + const utilRequires = getNodeRequireCalls(ast, 'util'); + const utilNamespace = getRequireNamespaceIdentifier(utilRequires[0]); + assert.strictEqual(utilNamespace?.text(), 'util'); + }); + + it("shouldn't catch standalone require calls", () => { + const code = dedent` + require("side-effect-only"); + const fs = require('fs'); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + // Standalone require calls should not be caught + const sideEffectRequires = getNodeRequireCalls(ast, 'side-effect-only'); + assert.strictEqual(sideEffectRequires.length, 0, "Standalone require calls should not be caught"); + + // But assigned require calls should be caught + const fsRequires = getNodeRequireCalls(ast, 'fs'); + assert.strictEqual(fsRequires.length, 1); + }); + + it("should handle edge cases for require calls", () => { + const code = dedent` + const fs = require('fs'); + const empty = require(); + const dynamic = require(variable); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + // Test modules that don't exist + const nonExistentRequires = getNodeRequireCalls(ast, 'non-existent'); + assert.strictEqual(nonExistentRequires.length, 0); + + // Test dynamic requires (with variables) should not be caught + const variableRequires = getNodeRequireCalls(ast, 'variable'); + assert.strictEqual(variableRequires.length, 0); + }); + + it("should handle different variable declaration types", () => { + const code = dedent` + const fs1 = require('fs'); + var fs2 = require('fs'); + let fs3 = require('fs'); + `; + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + + const fsRequires = getNodeRequireCalls(ast, 'fs'); + assert.strictEqual(fsRequires.length, 3); + assert.strictEqual(getRequireNamespaceIdentifier(fsRequires[0])?.text(), 'fs1'); + assert.strictEqual(getRequireNamespaceIdentifier(fsRequires[1])?.text(), 'fs2'); + assert.strictEqual(getRequireNamespaceIdentifier(fsRequires[2])?.text(), 'fs3'); + }); }); diff --git a/utils/src/ast-grep/require-call.ts b/utils/src/ast-grep/require-call.ts index 609de9f6..8f57c854 100644 --- a/utils/src/ast-grep/require-call.ts +++ b/utils/src/ast-grep/require-call.ts @@ -86,3 +86,14 @@ export const getNodeRequireCalls = (rootNode: SgRoot, nodeModuleName: string } }); +/** + * Get the identifier from a namespace require (e.g., const util = require('util')) + */ +export const getRequireNamespaceIdentifier = (requireNode: SgNode): SgNode | null => { + // First check if the name field is an identifier (not an object_pattern) + const nameField = requireNode.field('name'); + if (nameField && nameField.kind() === 'identifier') { + return nameField; + } + return null; +};