diff --git a/package-lock.json b/package-lock.json index a3ac6e9a..3ced9a5b 100644 --- a/package-lock.json +++ b/package-lock.json @@ -448,6 +448,10 @@ "resolved": "recipes/dirent-path-to-parent-path", "link": true }, + "node_modules/@nodejs/dns-lookup-options-coercion": { + "resolved": "recipes/dns-lookup-options-coercion", + "link": true + }, "node_modules/@nodejs/err-invalid-callback": { "resolved": "recipes/err-invalid-callback", "link": true @@ -817,6 +821,17 @@ "@codemod.com/jssg-types": "^1.6.1" } }, + "recipes/dns-lookup-options-coercion": { + "name": "@nodejs/dns-lookup-options-coercion", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.0" + } + }, "recipes/err-invalid-callback": { "name": "@nodejs/err-invalid-callback", "version": "1.0.0", diff --git a/recipes/dns-lookup-options-coercion/README.md b/recipes/dns-lookup-options-coercion/README.md new file mode 100644 index 00000000..415cb7f3 --- /dev/null +++ b/recipes/dns-lookup-options-coercion/README.md @@ -0,0 +1,25 @@ +# dns.lookup options type coercion DEP0153 + +Handle DEP0153 by converting literal `dns.lookup()` and `dnsPromises.lookup()` option values to their proper types. + +See [DEP0153](https://nodejs.org/api/deprecations.html#dep0153-dnslookup-and-dnspromiseslookup-options-type-coercion). + +## Example + +```diff + const dns = require("node:dns"); + +- dns.lookup("example.com", { family: "4", all: 1 }, callback); ++ dns.lookup("example.com", { family: 4, all: true }, callback); +``` + +```diff + import { lookup } from "node:dns/promises"; + +- await lookup("example.com", { family: "6", verbatim: 0 }); ++ await lookup("example.com", { family: 6, verbatim: false }); +``` + +## Limitations + +This recipe only changes literal option values. Dynamic values such as `{ family: familyOption }` need manual review. diff --git a/recipes/dns-lookup-options-coercion/codemod.yaml b/recipes/dns-lookup-options-coercion/codemod.yaml new file mode 100644 index 00000000..a6f1ec3f --- /dev/null +++ b/recipes/dns-lookup-options-coercion/codemod.yaml @@ -0,0 +1,24 @@ +schema_version: "1.0" +name: "@nodejs/dns-lookup-options-coercion" +version: "1.0.0" +description: Handle DEP0153 by converting literal dns.lookup option values to their proper types. +author: Herrtian +license: MIT +workflow: workflow.yaml +category: migration +repository: https://github.com/nodejs/userland-migrations + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - nodejs + - dns + +registry: + access: public + visibility: public diff --git a/recipes/dns-lookup-options-coercion/package.json b/recipes/dns-lookup-options-coercion/package.json new file mode 100644 index 00000000..6490c125 --- /dev/null +++ b/recipes/dns-lookup-options-coercion/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/dns-lookup-options-coercion", + "version": "1.0.0", + "description": "Handle DEP0153 by converting literal dns.lookup option values to their proper types", + "type": "module", + "scripts": { + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/dns-lookup-options-coercion", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Herrtian", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/dns-lookup-options-coercion/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.0" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/dns-lookup-options-coercion/src/workflow.ts b/recipes/dns-lookup-options-coercion/src/workflow.ts new file mode 100644 index 00000000..0061a048 --- /dev/null +++ b/recipes/dns-lookup-options-coercion/src/workflow.ts @@ -0,0 +1,186 @@ +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; +import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; + +const NUMERIC_OPTIONS = new Set(['family', 'hints']); +const BOOLEAN_OPTIONS = new Set(['all', 'verbatim']); + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + const lookupCallees = collectLookupCallees(root); + + if (!lookupCallees.size) return null; + + for (const callee of lookupCallees) { + const calls = rootNode.findAll<'call_expression'>({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: callee.includes('.') ? 'member_expression' : 'identifier', + pattern: callee, + }, + }, + }); + + for (const call of calls) { + processLookupCall(call, edits); + } + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} + +function collectLookupCallees(root: SgRoot): Set { + const callees = new Set(); + + for (const statement of getModuleDependencies(root, 'dns')) { + addResolvedBinding(callees, statement, '$.lookup'); + addResolvedBinding(callees, statement, '$.promises.lookup'); + } + + for (const statement of getModuleDependencies(root, 'dns/promises')) { + addResolvedBinding(callees, statement, '$.lookup'); + } + + return callees; +} + +function addResolvedBinding( + callees: Set, + statement: SgNode, + path: string, +): void { + const binding = resolveBindingPath(statement, path); + + if (binding) { + callees.add(binding); + } +} + +/** + * Rewrites the second argument only when it is an inline options object. + * + * DEP0153 affects option values, so calls without options or with a shared + * options variable are intentionally left unchanged for manual review. + */ +function processLookupCall(call: SgNode, edits: Edit[]): void { + const args = call.field('arguments'); + if (!args) return; + + const optionArg = args.children().filter((child) => child.isNamed())[1]; + + if (!optionArg || optionArg.kind() !== 'object') return; + + edits.push(...transformOptionsObject(optionArg)); +} + +/** + * Converts known dns.lookup option keys when the value can be replaced without + * changing surrounding code. Other keys are ignored so this recipe stays scoped + * to the DEP0153 runtime coercions. + */ +function transformOptionsObject(options: SgNode): Edit[] { + const edits: Edit[] = []; + const pairs = options.findAll<'pair'>({ rule: { kind: 'pair' } }); + + for (const pair of pairs) { + const key = getOptionKey(pair); + const value = pair.field('value'); + + if (!key || !value) continue; + + const replacement = getValueReplacement(key, value); + + if (replacement !== null) { + edits.push(value.replace(replacement)); + } + } + + return edits; +} + +function getOptionKey(pair: SgNode): string | null { + const key = pair.field('key'); + if (!key) return null; + + const keyKind = key.kind(); + if (keyKind === 'property_identifier') { + return key.text(); + } + + if (keyKind === 'string') { + return getStringLiteralValue(key); + } + + return null; +} + +/** + * Returns a replacement for deprecated literal coercions only. + * + * Semantic propagation for identifiers is deliberately avoided here: options + * objects can be reused, mutated, or passed through helpers, and an unsafe + * rewrite would be harder to review than leaving those cases for the user. + */ +function getValueReplacement(key: string, value: SgNode): string | null { + if (NUMERIC_OPTIONS.has(key)) { + return getNumericReplacement(value); + } + + if (BOOLEAN_OPTIONS.has(key)) { + return getBooleanReplacement(value); + } + + return null; +} + +function getNumericReplacement(value: SgNode): string | null { + const valueKind = value.kind(); + + switch (valueKind) { + case 'string': { + const stringValue = getStringLiteralValue(value); + if (!stringValue || !/^\d+$/.test(stringValue)) return null; + + return String(Number.parseInt(stringValue, 10)); + } + default: + return null; + } +} + +function getBooleanReplacement(value: SgNode): string | null { + const valueKind = value.kind(); + + switch (valueKind) { + case 'number': + if (value.text() === '0') return 'false'; + if (value.text() === '1') return 'true'; + return null; + case 'string': { + const stringValue = getStringLiteralValue(value); + return stringValue === 'true' || stringValue === 'false' + ? stringValue + : null; + } + default: + return null; + } +} + +function getStringLiteralValue(node: SgNode): string | null { + const stringFragment = node.find({ rule: { kind: 'string_fragment' } }); + + if (stringFragment) { + return stringFragment.text(); + } + + const text = node.text(); + + return text.length >= 2 ? text.slice(1, -1) : null; +} diff --git a/recipes/dns-lookup-options-coercion/tests/commonjs-destructured/expected.js b/recipes/dns-lookup-options-coercion/tests/commonjs-destructured/expected.js new file mode 100644 index 00000000..f58c7c55 --- /dev/null +++ b/recipes/dns-lookup-options-coercion/tests/commonjs-destructured/expected.js @@ -0,0 +1,3 @@ +const { lookup } = require("node:dns"); + +lookup("example.com", { family: 4, all: false }, callback); diff --git a/recipes/dns-lookup-options-coercion/tests/commonjs-destructured/input.js b/recipes/dns-lookup-options-coercion/tests/commonjs-destructured/input.js new file mode 100644 index 00000000..bec62075 --- /dev/null +++ b/recipes/dns-lookup-options-coercion/tests/commonjs-destructured/input.js @@ -0,0 +1,3 @@ +const { lookup } = require("node:dns"); + +lookup("example.com", { family: "4", all: "false" }, callback); diff --git a/recipes/dns-lookup-options-coercion/tests/commonjs-namespace/expected.js b/recipes/dns-lookup-options-coercion/tests/commonjs-namespace/expected.js new file mode 100644 index 00000000..4983e872 --- /dev/null +++ b/recipes/dns-lookup-options-coercion/tests/commonjs-namespace/expected.js @@ -0,0 +1,3 @@ +const dns = require("node:dns"); + +dns.lookup("example.com", { family: 4, hints: 0, all: true, verbatim: false }, callback); diff --git a/recipes/dns-lookup-options-coercion/tests/commonjs-namespace/input.js b/recipes/dns-lookup-options-coercion/tests/commonjs-namespace/input.js new file mode 100644 index 00000000..8d28754c --- /dev/null +++ b/recipes/dns-lookup-options-coercion/tests/commonjs-namespace/input.js @@ -0,0 +1,3 @@ +const dns = require("node:dns"); + +dns.lookup("example.com", { family: "4", hints: "0", all: 1, verbatim: 0 }, callback); diff --git a/recipes/dns-lookup-options-coercion/tests/dns-promises-import/expected.mjs b/recipes/dns-lookup-options-coercion/tests/dns-promises-import/expected.mjs new file mode 100644 index 00000000..f1edc75b --- /dev/null +++ b/recipes/dns-lookup-options-coercion/tests/dns-promises-import/expected.mjs @@ -0,0 +1,3 @@ +import { lookup } from "node:dns/promises"; + +await lookup("example.com", { family: 6, all: true }); diff --git a/recipes/dns-lookup-options-coercion/tests/dns-promises-import/input.mjs b/recipes/dns-lookup-options-coercion/tests/dns-promises-import/input.mjs new file mode 100644 index 00000000..f6dd9ba5 --- /dev/null +++ b/recipes/dns-lookup-options-coercion/tests/dns-promises-import/input.mjs @@ -0,0 +1,3 @@ +import { lookup } from "node:dns/promises"; + +await lookup("example.com", { family: "6", all: "true" }); diff --git a/recipes/dns-lookup-options-coercion/tests/leave-dynamic-options/expected.js b/recipes/dns-lookup-options-coercion/tests/leave-dynamic-options/expected.js new file mode 100644 index 00000000..c91489c8 --- /dev/null +++ b/recipes/dns-lookup-options-coercion/tests/leave-dynamic-options/expected.js @@ -0,0 +1,3 @@ +const dns = require("node:dns"); + +dns.lookup("example.com", { family: familyOption, hints: dns.ADDRCONFIG, all: shouldReturnAll }, callback); diff --git a/recipes/dns-lookup-options-coercion/tests/leave-dynamic-options/input.js b/recipes/dns-lookup-options-coercion/tests/leave-dynamic-options/input.js new file mode 100644 index 00000000..c91489c8 --- /dev/null +++ b/recipes/dns-lookup-options-coercion/tests/leave-dynamic-options/input.js @@ -0,0 +1,3 @@ +const dns = require("node:dns"); + +dns.lookup("example.com", { family: familyOption, hints: dns.ADDRCONFIG, all: shouldReturnAll }, callback); diff --git a/recipes/dns-lookup-options-coercion/tests/promises-alias/expected.mjs b/recipes/dns-lookup-options-coercion/tests/promises-alias/expected.mjs new file mode 100644 index 00000000..bc284a39 --- /dev/null +++ b/recipes/dns-lookup-options-coercion/tests/promises-alias/expected.mjs @@ -0,0 +1,3 @@ +import { promises as dnsPromises } from "node:dns"; + +await dnsPromises.lookup("example.com", { family: 4, verbatim: false }); diff --git a/recipes/dns-lookup-options-coercion/tests/promises-alias/input.mjs b/recipes/dns-lookup-options-coercion/tests/promises-alias/input.mjs new file mode 100644 index 00000000..a5fe946a --- /dev/null +++ b/recipes/dns-lookup-options-coercion/tests/promises-alias/input.mjs @@ -0,0 +1,3 @@ +import { promises as dnsPromises } from "node:dns"; + +await dnsPromises.lookup("example.com", { family: "4", verbatim: "false" }); diff --git a/recipes/dns-lookup-options-coercion/workflow.yaml b/recipes/dns-lookup-options-coercion/workflow.yaml new file mode 100644 index 00000000..86499726 --- /dev/null +++ b/recipes/dns-lookup-options-coercion/workflow.yaml @@ -0,0 +1,25 @@ +# yaml-language-server: $schema=https://raw.githubusercontent.com/codemod-com/codemod/refs/heads/main/schemas/workflow.json + +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + steps: + - name: Handle DEP0153 by converting literal dns.lookup option values to their proper types. + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript