From bdb92bff2d5cb13790f703ccf6da3fd50b214e2e Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Wed, 27 May 2026 13:48:15 +0200 Subject: [PATCH 1/5] feat(colors-to-util.styleText): add migration recipe --- package-lock.json | 15 + recipes/colors-to-util-styletext/README.md | 41 ++ recipes/colors-to-util-styletext/codemod.yaml | 26 + recipes/colors-to-util-styletext/package.json | 26 + .../src/remove-dependencies.ts | 13 + .../colors-to-util-styletext/src/workflow.ts | 468 ++++++++++++++++++ .../tests/basic-property/expected.js | 3 + .../tests/basic-property/input.js | 3 + .../tests/chained-property/expected.mjs | 3 + .../tests/chained-property/input.mjs | 3 + .../tests/concat-and-template/expected.js | 6 + .../tests/concat-and-template/input.js | 6 + .../tests/destructured-safe/expected.js | 4 + .../tests/destructured-safe/input.js | 4 + .../remove-colors/expected.json | 10 + .../remove-colors/input.json | 12 + .../tests/safe-chained-esm/expected.mjs | 3 + .../tests/safe-chained-esm/input.mjs | 3 + .../tests/safe-commonjs/expected.js | 3 + .../tests/safe-commonjs/input.js | 3 + .../tests/side-effect-import/expected.mjs | 3 + .../tests/side-effect-import/input.mjs | 3 + .../tests/unsupported-extra/expected.js | 3 + .../tests/unsupported-extra/input.js | 3 + .../colors-to-util-styletext/workflow.yaml | 42 ++ 25 files changed, 709 insertions(+) create mode 100644 recipes/colors-to-util-styletext/README.md create mode 100644 recipes/colors-to-util-styletext/codemod.yaml create mode 100644 recipes/colors-to-util-styletext/package.json create mode 100644 recipes/colors-to-util-styletext/src/remove-dependencies.ts create mode 100644 recipes/colors-to-util-styletext/src/workflow.ts create mode 100644 recipes/colors-to-util-styletext/tests/basic-property/expected.js create mode 100644 recipes/colors-to-util-styletext/tests/basic-property/input.js create mode 100644 recipes/colors-to-util-styletext/tests/chained-property/expected.mjs create mode 100644 recipes/colors-to-util-styletext/tests/chained-property/input.mjs create mode 100644 recipes/colors-to-util-styletext/tests/concat-and-template/expected.js create mode 100644 recipes/colors-to-util-styletext/tests/concat-and-template/input.js create mode 100644 recipes/colors-to-util-styletext/tests/destructured-safe/expected.js create mode 100644 recipes/colors-to-util-styletext/tests/destructured-safe/input.js create mode 100644 recipes/colors-to-util-styletext/tests/remove-dependencies/remove-colors/expected.json create mode 100644 recipes/colors-to-util-styletext/tests/remove-dependencies/remove-colors/input.json create mode 100644 recipes/colors-to-util-styletext/tests/safe-chained-esm/expected.mjs create mode 100644 recipes/colors-to-util-styletext/tests/safe-chained-esm/input.mjs create mode 100644 recipes/colors-to-util-styletext/tests/safe-commonjs/expected.js create mode 100644 recipes/colors-to-util-styletext/tests/safe-commonjs/input.js create mode 100644 recipes/colors-to-util-styletext/tests/side-effect-import/expected.mjs create mode 100644 recipes/colors-to-util-styletext/tests/side-effect-import/input.mjs create mode 100644 recipes/colors-to-util-styletext/tests/unsupported-extra/expected.js create mode 100644 recipes/colors-to-util-styletext/tests/unsupported-extra/input.js create mode 100644 recipes/colors-to-util-styletext/workflow.yaml diff --git a/package-lock.json b/package-lock.json index a3ac6e9a..49d224df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -424,6 +424,10 @@ "resolved": "utils", "link": true }, + "node_modules/@nodejs/colors-to-util-styletext": { + "resolved": "recipes/colors-to-util-styletext", + "link": true + }, "node_modules/@nodejs/create-require-from-path": { "resolved": "recipes/create-require-from-path", "link": true @@ -750,6 +754,17 @@ "@codemod.com/jssg-types": "^1.6.1" } }, + "recipes/colors-to-util-styletext": { + "name": "@nodejs/colors-to-util-styletext", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.0" + } + }, "recipes/create-require-from-path": { "name": "@nodejs/create-require-from-path", "version": "1.1.2", diff --git a/recipes/colors-to-util-styletext/README.md b/recipes/colors-to-util-styletext/README.md new file mode 100644 index 00000000..37ef1211 --- /dev/null +++ b/recipes/colors-to-util-styletext/README.md @@ -0,0 +1,41 @@ +# Colors to util.styleText + +This recipe migrates compatible `colors` package usage to Node.js built-in `util.styleText`. + +## Examples + +```diff +- const colors = require('colors'); ++ const { styleText } = require('node:util'); +- console.log('Error message'.red); ++ console.log(styleText('red', 'Error message')); +``` + +```diff +- import colors from 'colors'; ++ import { styleText } from 'node:util'; +- console.log('Success'.green.bold); ++ console.log(styleText(['green', 'bold'], 'Success')); +``` + +```diff +- const colors = require('colors/safe'); ++ const { styleText } = require('node:util'); +- console.log(colors.green('Success message')); ++ console.log(styleText('green', 'Success message')); +``` + +## Usage + +Run this codemod with: + +```sh +npx codemod nodejs/colors-to-util-styletext +``` + +## Compatibility + +- Removes the `colors` dependency from package.json automatically. +- Supports string prototype colors and modifiers, including chained styles. +- Supports `colors/safe` namespace calls. +- Unsupported extras such as `rainbow`, `zebra`, `america`, `trap`, and `random` are left unchanged and reported for manual review. diff --git a/recipes/colors-to-util-styletext/codemod.yaml b/recipes/colors-to-util-styletext/codemod.yaml new file mode 100644 index 00000000..8e8b7a44 --- /dev/null +++ b/recipes/colors-to-util-styletext/codemod.yaml @@ -0,0 +1,26 @@ +schema_version: "1.0" +name: "@nodejs/colors-to-util-styletext" +version: 1.0.0 +capabilities: + - fs + - child_process +description: Migrate from the colors package to Node.js's built-in util.styleText API +author: Herrtian +license: MIT +workflow: workflow.yaml +category: migration +repository: https://github.com/nodejs/userland-migrations + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - nodejs + +registry: + access: public + visibility: public diff --git a/recipes/colors-to-util-styletext/package.json b/recipes/colors-to-util-styletext/package.json new file mode 100644 index 00000000..55ad416f --- /dev/null +++ b/recipes/colors-to-util-styletext/package.json @@ -0,0 +1,26 @@ +{ + "name": "@nodejs/colors-to-util-styletext", + "version": "1.0.0", + "description": "Migrate from the colors package to Node.js's built-in util.styleText API", + "type": "module", + "scripts": { + "test": "node --run test:workflow && node --run test:remove-dependencies", + "test:workflow": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests", + "test:remove-dependencies": "npx codemod jssg test -l json ./src/remove-dependencies.ts ./tests/remove-dependencies --allow-child-process --allow-fs --strictness cst" + }, + "repository": { + "type": "git", + "url": "git+https://github.com/nodejs/userland-migrations.git", + "directory": "recipes/colors-to-util-styletext", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Herrtian", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/colors-to-util-styletext/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.0" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/colors-to-util-styletext/src/remove-dependencies.ts b/recipes/colors-to-util-styletext/src/remove-dependencies.ts new file mode 100644 index 00000000..581114b0 --- /dev/null +++ b/recipes/colors-to-util-styletext/src/remove-dependencies.ts @@ -0,0 +1,13 @@ +import type { Transform } from '@codemod.com/jssg-types/main'; +import type Json from '@codemod.com/jssg-types/langs/json'; +import removeDependencies from '@nodejs/codemod-utils/remove-dependencies'; + +const transform: Transform = async (root) => { + return removeDependencies(['colors', '@types/colors'], { + packageJsonPath: root.filename(), + runInstall: false, + persistFileWrite: false, + }); +}; + +export default transform; diff --git a/recipes/colors-to-util-styletext/src/workflow.ts b/recipes/colors-to-util-styletext/src/workflow.ts new file mode 100644 index 00000000..620f31d1 --- /dev/null +++ b/recipes/colors-to-util-styletext/src/workflow.ts @@ -0,0 +1,468 @@ +import type { Edit, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; +import { resolveBindingPath } from '@nodejs/codemod-utils/ast-grep/resolve-binding-path'; + +const colorsModule = 'colors'; +const safeColorsModule = 'colors/safe'; + +const COMPAT_MAP: Record = { + bgGrey: 'bgGray', + grey: 'gray', +}; + +const SUPPORTED_STYLES = new Set([ + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'gray', + 'grey', + 'blackBright', + 'redBright', + 'greenBright', + 'yellowBright', + 'blueBright', + 'magentaBright', + 'cyanBright', + 'whiteBright', + 'bgBlack', + 'bgRed', + 'bgGreen', + 'bgYellow', + 'bgBlue', + 'bgMagenta', + 'bgCyan', + 'bgWhite', + 'bgGray', + 'bgGrey', + 'bgBlackBright', + 'bgRedBright', + 'bgGreenBright', + 'bgYellowBright', + 'bgBlueBright', + 'bgMagentaBright', + 'bgCyanBright', + 'bgWhiteBright', + 'reset', + 'bold', + 'dim', + 'italic', + 'underline', + 'inverse', + 'hidden', + 'strikethrough', +]); + +const UNSUPPORTED_EXTRAS = new Set([ + 'america', + 'rainbow', + 'random', + 'trap', + 'zebra', +]); + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + + const colorsStatements = getModuleDependencies(root, colorsModule); + const safeColorsStatements = getModuleDependencies(root, safeColorsModule); + + if (!colorsStatements.length && !safeColorsStatements.length) return null; + + for (const statement of safeColorsStatements) { + processStatement(rootNode, statement, edits, true); + } + + for (const statement of colorsStatements) { + processStatement(rootNode, statement, edits, false); + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} + +function processStatement( + rootNode: SgNode, + statement: SgNode, + edits: Edit[], + isSafeImport: boolean, +): void { + const initialEditCount = edits.length; + const destructuredNames = getDestructuredNames(statement); + const blockedPrototypeBases = new Set(); + + if (destructuredNames.length > 0) { + processDestructuredSafeCalls(rootNode, destructuredNames, edits); + } else { + const binding = resolveOptionalBinding(statement); + + if (binding) { + blockedPrototypeBases.add(binding); + processSafeNamespaceCalls(rootNode, binding, edits); + } + } + + if (!isSafeImport) { + processPrototypeStyles(rootNode, edits, blockedPrototypeBases); + } + + if (edits.length > initialEditCount) { + const importReplacement = createImportReplacement(statement); + + if (importReplacement) { + edits.push(statement.replace(importReplacement)); + } + } +} + +function resolveOptionalBinding(statement: SgNode): string | undefined { + if ( + statement.kind() === 'import_statement' && + !statement.find({ rule: { kind: 'import_clause' } }) + ) { + return undefined; + } + + return resolveBindingPath(statement, '$'); +} + +function getDestructuredNames( + statement: SgNode, +): Array<{ imported: string; local: string }> { + const names: Array<{ imported: string; local: string }> = []; + + if (statement.kind() === 'import_statement') { + const namedImports = statement.find({ + rule: { kind: 'named_imports' }, + }); + + if (!namedImports) return names; + + const importSpecifiers = namedImports.findAll({ + rule: { kind: 'import_specifier' }, + }); + + for (const specifier of importSpecifiers) { + const importedName = specifier.field('name'); + const alias = specifier.field('alias'); + + if (importedName) { + const imported = importedName.text(); + names.push({ imported, local: alias ? alias.text() : imported }); + } + } + } else if (statement.kind() === 'variable_declarator') { + const nameField = statement.field('name'); + + if (nameField?.kind() !== 'object_pattern') return names; + + const properties = nameField.findAll({ + rule: { + any: [ + { kind: 'shorthand_property_identifier_pattern' }, + { kind: 'pair_pattern' }, + ], + }, + }); + + for (const prop of properties) { + if (prop.kind() === 'shorthand_property_identifier_pattern') { + const name = prop.text(); + + names.push({ imported: name, local: name }); + } else if (prop.kind() === 'pair_pattern') { + const key = prop.field('key'); + const value = prop.field('value'); + + if (key && value) { + names.push({ imported: key.text(), local: value.text() }); + } + } + } + } + + return names; +} + +function processDestructuredSafeCalls( + rootNode: SgNode, + destructuredNames: Array<{ imported: string; local: string }>, + edits: Edit[], +): void { + for (const { imported, local } of destructuredNames) { + const calls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + any: [ + { kind: 'identifier', pattern: local }, + { + kind: 'member_expression', + has: { + field: 'object', + any: [ + { kind: 'identifier', pattern: local }, + { + kind: 'member_expression', + has: { + field: 'object', + kind: 'identifier', + pattern: local, + }, + }, + ], + }, + }, + ], + }, + }, + }); + + for (const call of calls) { + const functionExpr = call.field('function'); + if (!functionExpr) continue; + + const styles = + functionExpr.kind() === 'identifier' + ? [normalizeStyle(imported)] + : extractNamespaceStyles(functionExpr, local, normalizeStyle(imported)); + + replaceSafeCall(rootNode, call, styles, edits); + } + } +} + +function processSafeNamespaceCalls( + rootNode: SgNode, + binding: string, + edits: Edit[], +): void { + const calls = rootNode.findAll({ + rule: { kind: 'call_expression' }, + }); + + for (const call of calls) { + const functionExpr = call.field('function'); + + if (functionExpr?.kind() !== 'member_expression') continue; + + const styles = extractNamespaceStyles(functionExpr, binding); + replaceSafeCall(rootNode, call, styles, edits); + } +} + +function replaceSafeCall( + rootNode: SgNode, + call: SgNode, + styles: string[], + edits: Edit[], +): void { + if (styles.length === 0) return; + + if (hasUnsupportedStyles(styles)) { + warnOnUnsupportedStyle(styles, rootNode, call); + return; + } + + const textArg = getFirstCallArgument(call); + + if (!textArg) return; + + edits.push(call.replace(createStyleTextReplacement(styles, textArg))); +} + +function processPrototypeStyles( + rootNode: SgNode, + edits: Edit[], + blockedPrototypeBases: Set, +): void { + const memberExpressions = rootNode.findAll({ + rule: { kind: 'member_expression' }, + }); + + for (const memberExpression of memberExpressions) { + if (isNestedStyleChain(memberExpression)) continue; + if (isCallFunction(memberExpression)) continue; + + const prototypeStyle = extractPrototypeStyles(memberExpression); + + if (!prototypeStyle) continue; + if (blockedPrototypeBases.has(prototypeStyle.text)) continue; + + if (hasUnsupportedStyles(prototypeStyle.styles)) { + warnOnUnsupportedStyle(prototypeStyle.styles, rootNode, memberExpression); + continue; + } + + edits.push( + memberExpression.replace( + createStyleTextReplacement(prototypeStyle.styles, prototypeStyle.text), + ), + ); + } +} + +function extractNamespaceStyles( + node: SgNode, + binding: string, + initialStyle?: string, +): string[] { + const styles = initialStyle ? [initialStyle] : []; + + function traverse(current: SgNode): boolean { + const object = current.field('object'); + const property = current.field('property'); + + if (!object || property?.kind() !== 'property_identifier') return false; + + const propertyName = normalizeStyle(property.text()); + + if (object.kind() === 'identifier' && object.text() === binding) { + styles.push(propertyName); + return true; + } + + if (object.kind() === 'member_expression' && traverse(object)) { + styles.push(propertyName); + return true; + } + + return false; + } + + traverse(node); + + return styles; +} + +function extractPrototypeStyles( + node: SgNode, +): { text: string; styles: string[] } | null { + const property = node.field('property'); + const object = node.field('object'); + + if (!object || property?.kind() !== 'property_identifier') return null; + + const style = normalizeStyle(property.text()); + + if (!isSupportedOrKnownUnsupported(style)) return null; + + if (object.kind() === 'member_expression') { + const nested = extractPrototypeStyles(object); + + if (!nested) return null; + + return { text: nested.text, styles: [...nested.styles, style] }; + } + + if (!isSupportedPrototypeBase(object)) return null; + + return { text: object.text(), styles: [style] }; +} + +function isSupportedPrototypeBase(node: SgNode): boolean { + return [ + 'identifier', + 'parenthesized_expression', + 'string', + 'template_string', + ].includes(node.kind()); +} + +function normalizeStyle(style: string): string { + return COMPAT_MAP[style] || style; +} + +function isSupportedOrKnownUnsupported(style: string): boolean { + return SUPPORTED_STYLES.has(style) || UNSUPPORTED_EXTRAS.has(style); +} + +function hasUnsupportedStyles(styles: string[]): boolean { + return styles.some((style) => !SUPPORTED_STYLES.has(style)); +} + +function getFirstCallArgument(call: SgNode): string | null { + const args = call.field('arguments'); + + if (!args) return null; + + const argsList = args.children().filter((child) => { + const excluded = [',', '(', ')']; + return !excluded.includes(child.kind()); + }); + + if (argsList.length === 0) return null; + + return argsList[0].text(); +} + +function createStyleTextReplacement(styles: string[], textArg: string): string { + if (styles.length === 1) { + return `styleText("${styles[0]}", ${textArg})`; + } + + return `styleText([${styles.map((style) => `"${style}"`).join(', ')}], ${textArg})`; +} + +function createImportReplacement(statement: SgNode): string { + if (statement.kind() === 'import_statement') { + return 'import { styleText } from "node:util";'; + } + + if (statement.kind() === 'variable_declarator') { + if (statement.field('value')?.kind() === 'await_expression') { + return '{ styleText } = await import("node:util")'; + } + + return '{ styleText } = require("node:util")'; + } + + return ''; +} + +function isNestedStyleChain(node: SgNode): boolean { + const parent = node.parent(); + + if (parent?.kind() !== 'member_expression') return false; + + const object = parent.field('object'); + const property = parent.field('property'); + + return ( + object?.text() === node.text() && + property?.kind() === 'property_identifier' && + isSupportedOrKnownUnsupported(normalizeStyle(property.text())) + ); +} + +function isCallFunction(node: SgNode): boolean { + const parent = node.parent(); + + if (parent?.kind() !== 'call_expression') return false; + + return parent.field('function')?.text() === node.text(); +} + +function warnOnUnsupportedStyle( + styles: string[], + rootNode: SgNode, + node: SgNode, +): void { + const filename = rootNode.getRoot().filename(); + const { start } = node.range(); + const unsupported = styles.filter((style) => !SUPPORTED_STYLES.has(style)); + + for (const style of unsupported) { + console.warn( + `${filename}:${start.line}:${start.column}: uses colors style '${style}' that does not have any equivalent in util.styleText; please review this line`, + ); + } +} diff --git a/recipes/colors-to-util-styletext/tests/basic-property/expected.js b/recipes/colors-to-util-styletext/tests/basic-property/expected.js new file mode 100644 index 00000000..10711813 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/basic-property/expected.js @@ -0,0 +1,3 @@ +const { styleText } = require("node:util"); + +console.log(styleText("red", "Error message")); diff --git a/recipes/colors-to-util-styletext/tests/basic-property/input.js b/recipes/colors-to-util-styletext/tests/basic-property/input.js new file mode 100644 index 00000000..84ffb60a --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/basic-property/input.js @@ -0,0 +1,3 @@ +const colors = require("colors"); + +console.log("Error message".red); diff --git a/recipes/colors-to-util-styletext/tests/chained-property/expected.mjs b/recipes/colors-to-util-styletext/tests/chained-property/expected.mjs new file mode 100644 index 00000000..6ee4176f --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/chained-property/expected.mjs @@ -0,0 +1,3 @@ +import { styleText } from "node:util"; + +console.log(styleText(["green", "bold"], "Success message")); diff --git a/recipes/colors-to-util-styletext/tests/chained-property/input.mjs b/recipes/colors-to-util-styletext/tests/chained-property/input.mjs new file mode 100644 index 00000000..97be8c8f --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/chained-property/input.mjs @@ -0,0 +1,3 @@ +import colors from "colors"; + +console.log("Success message".green.bold); diff --git a/recipes/colors-to-util-styletext/tests/concat-and-template/expected.js b/recipes/colors-to-util-styletext/tests/concat-and-template/expected.js new file mode 100644 index 00000000..9934acb5 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/concat-and-template/expected.js @@ -0,0 +1,6 @@ +const { styleText } = require("node:util"); + +const name = "World"; +const action = "ready"; +console.log("Hello, " + styleText("green", name) + "!"); +console.log(`${styleText("blue", "[INFO]")} User ${styleText("green", name)} is ${styleText("yellow", action)}`); diff --git a/recipes/colors-to-util-styletext/tests/concat-and-template/input.js b/recipes/colors-to-util-styletext/tests/concat-and-template/input.js new file mode 100644 index 00000000..a1f9edac --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/concat-and-template/input.js @@ -0,0 +1,6 @@ +const colors = require("colors"); + +const name = "World"; +const action = "ready"; +console.log("Hello, " + name.green + "!"); +console.log(`${"[INFO]".blue} User ${name.green} is ${action.yellow}`); diff --git a/recipes/colors-to-util-styletext/tests/destructured-safe/expected.js b/recipes/colors-to-util-styletext/tests/destructured-safe/expected.js new file mode 100644 index 00000000..617bc05c --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/destructured-safe/expected.js @@ -0,0 +1,4 @@ +const { styleText } = require("node:util"); + +console.log(styleText("red", "Error message")); +console.log(styleText("green", "Success message")); diff --git a/recipes/colors-to-util-styletext/tests/destructured-safe/input.js b/recipes/colors-to-util-styletext/tests/destructured-safe/input.js new file mode 100644 index 00000000..b927409f --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/destructured-safe/input.js @@ -0,0 +1,4 @@ +const { red, green: success } = require("colors/safe"); + +console.log(red("Error message")); +console.log(success("Success message")); diff --git a/recipes/colors-to-util-styletext/tests/remove-dependencies/remove-colors/expected.json b/recipes/colors-to-util-styletext/tests/remove-dependencies/remove-colors/expected.json new file mode 100644 index 00000000..46578268 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/remove-dependencies/remove-colors/expected.json @@ -0,0 +1,10 @@ +{ + "name": "fixture", + "version": "1.0.0", + "dependencies": { + "kleur": "^4.1.5" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} diff --git a/recipes/colors-to-util-styletext/tests/remove-dependencies/remove-colors/input.json b/recipes/colors-to-util-styletext/tests/remove-dependencies/remove-colors/input.json new file mode 100644 index 00000000..33682f2c --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/remove-dependencies/remove-colors/input.json @@ -0,0 +1,12 @@ +{ + "name": "fixture", + "version": "1.0.0", + "dependencies": { + "colors": "^1.4.0", + "kleur": "^4.1.5" + }, + "devDependencies": { + "@types/colors": "^1.2.1", + "typescript": "^5.6.0" + } +} diff --git a/recipes/colors-to-util-styletext/tests/safe-chained-esm/expected.mjs b/recipes/colors-to-util-styletext/tests/safe-chained-esm/expected.mjs new file mode 100644 index 00000000..6ee4176f --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/safe-chained-esm/expected.mjs @@ -0,0 +1,3 @@ +import { styleText } from "node:util"; + +console.log(styleText(["green", "bold"], "Success message")); diff --git a/recipes/colors-to-util-styletext/tests/safe-chained-esm/input.mjs b/recipes/colors-to-util-styletext/tests/safe-chained-esm/input.mjs new file mode 100644 index 00000000..ff00f655 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/safe-chained-esm/input.mjs @@ -0,0 +1,3 @@ +import colors from "colors/safe"; + +console.log(colors.green.bold("Success message")); diff --git a/recipes/colors-to-util-styletext/tests/safe-commonjs/expected.js b/recipes/colors-to-util-styletext/tests/safe-commonjs/expected.js new file mode 100644 index 00000000..72ec84d5 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/safe-commonjs/expected.js @@ -0,0 +1,3 @@ +const { styleText } = require("node:util"); + +console.log(styleText("green", "Success message")); diff --git a/recipes/colors-to-util-styletext/tests/safe-commonjs/input.js b/recipes/colors-to-util-styletext/tests/safe-commonjs/input.js new file mode 100644 index 00000000..131f221b --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/safe-commonjs/input.js @@ -0,0 +1,3 @@ +const colors = require("colors/safe"); + +console.log(colors.green("Success message")); diff --git a/recipes/colors-to-util-styletext/tests/side-effect-import/expected.mjs b/recipes/colors-to-util-styletext/tests/side-effect-import/expected.mjs new file mode 100644 index 00000000..c3bf37c5 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/side-effect-import/expected.mjs @@ -0,0 +1,3 @@ +import { styleText } from "node:util"; + +console.log(styleText("red", "Error message")); diff --git a/recipes/colors-to-util-styletext/tests/side-effect-import/input.mjs b/recipes/colors-to-util-styletext/tests/side-effect-import/input.mjs new file mode 100644 index 00000000..3bfc9627 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/side-effect-import/input.mjs @@ -0,0 +1,3 @@ +import "colors"; + +console.log("Error message".red); diff --git a/recipes/colors-to-util-styletext/tests/unsupported-extra/expected.js b/recipes/colors-to-util-styletext/tests/unsupported-extra/expected.js new file mode 100644 index 00000000..04daea23 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/unsupported-extra/expected.js @@ -0,0 +1,3 @@ +const colors = require("colors"); + +console.log("Party".rainbow); diff --git a/recipes/colors-to-util-styletext/tests/unsupported-extra/input.js b/recipes/colors-to-util-styletext/tests/unsupported-extra/input.js new file mode 100644 index 00000000..04daea23 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/unsupported-extra/input.js @@ -0,0 +1,3 @@ +const colors = require("colors"); + +console.log("Party".rainbow); diff --git a/recipes/colors-to-util-styletext/workflow.yaml b/recipes/colors-to-util-styletext/workflow.yaml new file mode 100644 index 00000000..76b00334 --- /dev/null +++ b/recipes/colors-to-util-styletext/workflow.yaml @@ -0,0 +1,42 @@ +# 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: Migrate from the colors package to Node.js's built-in util.styleText API + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.cjs" + - "**/*.cts" + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript + + - id: remove-dependencies + name: Remove colors dependency + type: automatic + steps: + - name: Detect package manager and remove colors dependency + js-ast-grep: + js_file: src/remove-dependencies.ts + base_path: . + include: + - "**/package.json" + exclude: + - "**/node_modules/**" + language: typescript + capabilities: + - child_process + - fs From 783944564d7369fca4d51b3b44a65887aedbb205 Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Wed, 27 May 2026 16:07:59 +0200 Subject: [PATCH 2/5] Address colors migration review --- .../colors-to-util-styletext/src/workflow.ts | 49 +++++++++++++++---- .../tests/safe-unrelated-call/expected.js | 3 ++ .../tests/safe-unrelated-call/input.js | 3 ++ 3 files changed, 46 insertions(+), 9 deletions(-) create mode 100644 recipes/colors-to-util-styletext/tests/safe-unrelated-call/expected.js create mode 100644 recipes/colors-to-util-styletext/tests/safe-unrelated-call/input.js diff --git a/recipes/colors-to-util-styletext/src/workflow.ts b/recipes/colors-to-util-styletext/src/workflow.ts index 620f31d1..c462a080 100644 --- a/recipes/colors-to-util-styletext/src/workflow.ts +++ b/recipes/colors-to-util-styletext/src/workflow.ts @@ -66,6 +66,7 @@ const UNSUPPORTED_EXTRAS = new Set([ 'zebra', ]); +/** Converts supported colors imports and usages to util.styleText. */ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; @@ -88,6 +89,7 @@ export default function transform(root: SgRoot): string | null { return rootNode.commitEdits(edits); } +/** Rewrites one colors dependency statement and its related usages. */ function processStatement( rootNode: SgNode, statement: SgNode, @@ -122,9 +124,10 @@ function processStatement( } } +/** Returns the local binding for namespace imports/requires when one exists. */ function resolveOptionalBinding(statement: SgNode): string | undefined { if ( - statement.kind() === 'import_statement' && + statement.is('import_statement') && !statement.find({ rule: { kind: 'import_clause' } }) ) { return undefined; @@ -133,12 +136,14 @@ function resolveOptionalBinding(statement: SgNode): string | undefined { return resolveBindingPath(statement, '$'); } +/** Collects imported colors names from destructured imports and requires. */ function getDestructuredNames( statement: SgNode, ): Array<{ imported: string; local: string }> { const names: Array<{ imported: string; local: string }> = []; + const statementKind = statement.kind(); - if (statement.kind() === 'import_statement') { + if (statementKind === 'import_statement') { const namedImports = statement.find({ rule: { kind: 'named_imports' }, }); @@ -158,7 +163,7 @@ function getDestructuredNames( names.push({ imported, local: alias ? alias.text() : imported }); } } - } else if (statement.kind() === 'variable_declarator') { + } else if (statementKind === 'variable_declarator') { const nameField = statement.field('name'); if (nameField?.kind() !== 'object_pattern') return names; @@ -191,6 +196,7 @@ function getDestructuredNames( return names; } +/** Rewrites calls that use destructured colors/safe helpers. */ function processDestructuredSafeCalls( rootNode: SgNode, destructuredNames: Array<{ imported: string; local: string }>, @@ -233,13 +239,18 @@ function processDestructuredSafeCalls( const styles = functionExpr.kind() === 'identifier' ? [normalizeStyle(imported)] - : extractNamespaceStyles(functionExpr, local, normalizeStyle(imported)); + : extractNamespaceStyles( + functionExpr, + local, + normalizeStyle(imported), + ); replaceSafeCall(rootNode, call, styles, edits); } } } +/** Rewrites calls accessed through a colors/safe namespace binding. */ function processSafeNamespaceCalls( rootNode: SgNode, binding: string, @@ -259,13 +270,14 @@ function processSafeNamespaceCalls( } } +/** Replaces a safe colors call with util.styleText when it can be mapped. */ function replaceSafeCall( rootNode: SgNode, call: SgNode, styles: string[], edits: Edit[], ): void { - if (styles.length === 0) return; + if (!styles.length) return; if (hasUnsupportedStyles(styles)) { warnOnUnsupportedStyle(styles, rootNode, call); @@ -279,6 +291,7 @@ function replaceSafeCall( edits.push(call.replace(createStyleTextReplacement(styles, textArg))); } +/** Rewrites colors prototype style chains such as "text".green. */ function processPrototypeStyles( rootNode: SgNode, edits: Edit[], @@ -289,13 +302,18 @@ function processPrototypeStyles( }); for (const memberExpression of memberExpressions) { - if (isNestedStyleChain(memberExpression)) continue; - if (isCallFunction(memberExpression)) continue; + if ( + isNestedStyleChain(memberExpression) || + isCallFunction(memberExpression) + ) { + continue; + } const prototypeStyle = extractPrototypeStyles(memberExpression); - if (!prototypeStyle) continue; - if (blockedPrototypeBases.has(prototypeStyle.text)) continue; + if (!prototypeStyle || blockedPrototypeBases.has(prototypeStyle.text)) { + continue; + } if (hasUnsupportedStyles(prototypeStyle.styles)) { warnOnUnsupportedStyle(prototypeStyle.styles, rootNode, memberExpression); @@ -310,6 +328,7 @@ function processPrototypeStyles( } } +/** Reads the style chain from a safe namespace call. */ function extractNamespaceStyles( node: SgNode, binding: string, @@ -317,6 +336,7 @@ function extractNamespaceStyles( ): string[] { const styles = initialStyle ? [initialStyle] : []; + /** Walks a member chain until it reaches the expected namespace binding. */ function traverse(current: SgNode): boolean { const object = current.field('object'); const property = current.field('property'); @@ -343,6 +363,7 @@ function extractNamespaceStyles( return styles; } +/** Reads the style chain from a prototype access expression. */ function extractPrototypeStyles( node: SgNode, ): { text: string; styles: string[] } | null { @@ -368,6 +389,7 @@ function extractPrototypeStyles( return { text: object.text(), styles: [style] }; } +/** Checks whether a node can safely be used as the text argument. */ function isSupportedPrototypeBase(node: SgNode): boolean { return [ 'identifier', @@ -377,18 +399,22 @@ function isSupportedPrototypeBase(node: SgNode): boolean { ].includes(node.kind()); } +/** Normalizes colors aliases to util.styleText names. */ function normalizeStyle(style: string): string { return COMPAT_MAP[style] || style; } +/** Keeps traversal limited to colors styles that are known to the recipe. */ function isSupportedOrKnownUnsupported(style: string): boolean { return SUPPORTED_STYLES.has(style) || UNSUPPORTED_EXTRAS.has(style); } +/** Detects styles that colors supports but util.styleText does not. */ function hasUnsupportedStyles(styles: string[]): boolean { return styles.some((style) => !SUPPORTED_STYLES.has(style)); } +/** Returns the first real argument for a colors/safe call. */ function getFirstCallArgument(call: SgNode): string | null { const args = call.field('arguments'); @@ -404,6 +430,7 @@ function getFirstCallArgument(call: SgNode): string | null { return argsList[0].text(); } +/** Builds a util.styleText call for one or more styles. */ function createStyleTextReplacement(styles: string[], textArg: string): string { if (styles.length === 1) { return `styleText("${styles[0]}", ${textArg})`; @@ -412,6 +439,7 @@ function createStyleTextReplacement(styles: string[], textArg: string): string { return `styleText([${styles.map((style) => `"${style}"`).join(', ')}], ${textArg})`; } +/** Builds the matching util.styleText import or require replacement. */ function createImportReplacement(statement: SgNode): string { if (statement.kind() === 'import_statement') { return 'import { styleText } from "node:util";'; @@ -428,6 +456,7 @@ function createImportReplacement(statement: SgNode): string { return ''; } +/** Skips intermediate members so only the full style chain is replaced. */ function isNestedStyleChain(node: SgNode): boolean { const parent = node.parent(); @@ -443,6 +472,7 @@ function isNestedStyleChain(node: SgNode): boolean { ); } +/** Skips member expressions that are already part of a call expression. */ function isCallFunction(node: SgNode): boolean { const parent = node.parent(); @@ -451,6 +481,7 @@ function isCallFunction(node: SgNode): boolean { return parent.field('function')?.text() === node.text(); } +/** Emits a review warning for colors styles that cannot be mapped. */ function warnOnUnsupportedStyle( styles: string[], rootNode: SgNode, diff --git a/recipes/colors-to-util-styletext/tests/safe-unrelated-call/expected.js b/recipes/colors-to-util-styletext/tests/safe-unrelated-call/expected.js new file mode 100644 index 00000000..b2f68913 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/safe-unrelated-call/expected.js @@ -0,0 +1,3 @@ +const colors = require("colors/safe"); + +console.log("plain message"); diff --git a/recipes/colors-to-util-styletext/tests/safe-unrelated-call/input.js b/recipes/colors-to-util-styletext/tests/safe-unrelated-call/input.js new file mode 100644 index 00000000..b2f68913 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/safe-unrelated-call/input.js @@ -0,0 +1,3 @@ +const colors = require("colors/safe"); + +console.log("plain message"); From 329b59cd806165b4c8356ced997f607ea47a78bb Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Thu, 28 May 2026 21:28:14 +0200 Subject: [PATCH 3/5] Address colors migration review comments Signed-off-by: Herrtian <70463940+Herrtian@users.noreply.github.com> --- .../colors-to-util-styletext/src/workflow.ts | 53 +++++++++++-------- .../safe-dynamic-import-then/expected.mjs | 3 ++ .../tests/safe-dynamic-import-then/input.mjs | 3 ++ 3 files changed, 36 insertions(+), 23 deletions(-) create mode 100644 recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/expected.mjs create mode 100644 recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/input.mjs diff --git a/recipes/colors-to-util-styletext/src/workflow.ts b/recipes/colors-to-util-styletext/src/workflow.ts index c462a080..d562278d 100644 --- a/recipes/colors-to-util-styletext/src/workflow.ts +++ b/recipes/colors-to-util-styletext/src/workflow.ts @@ -178,16 +178,23 @@ function getDestructuredNames( }); for (const prop of properties) { - if (prop.kind() === 'shorthand_property_identifier_pattern') { - const name = prop.text(); + const propKind = prop.kind(); - names.push({ imported: name, local: name }); - } else if (prop.kind() === 'pair_pattern') { - const key = prop.field('key'); - const value = prop.field('value'); + switch (propKind) { + case 'shorthand_property_identifier_pattern': { + const name = prop.text(); - if (key && value) { - names.push({ imported: key.text(), local: value.text() }); + names.push({ imported: name, local: name }); + break; + } + case 'pair_pattern': { + const key = prop.field('key'); + const value = prop.field('value'); + + if (key && value) { + names.push({ imported: key.text(), local: value.text() }); + } + break; } } } @@ -344,13 +351,14 @@ function extractNamespaceStyles( if (!object || property?.kind() !== 'property_identifier') return false; const propertyName = normalizeStyle(property.text()); + const objectKind = object.kind(); - if (object.kind() === 'identifier' && object.text() === binding) { + if (objectKind === 'identifier' && object.text() === binding) { styles.push(propertyName); return true; } - if (object.kind() === 'member_expression' && traverse(object)) { + if (objectKind === 'member_expression' && traverse(object)) { styles.push(propertyName); return true; } @@ -376,7 +384,7 @@ function extractPrototypeStyles( if (!isSupportedOrKnownUnsupported(style)) return null; - if (object.kind() === 'member_expression') { + if (object.is('member_expression')) { const nested = extractPrototypeStyles(object); if (!nested) return null; @@ -420,10 +428,7 @@ function getFirstCallArgument(call: SgNode): string | null { if (!args) return null; - const argsList = args.children().filter((child) => { - const excluded = [',', '(', ')']; - return !excluded.includes(child.kind()); - }); + const argsList = args.children().filter((child) => child.isNamed()); if (argsList.length === 0) return null; @@ -441,16 +446,18 @@ function createStyleTextReplacement(styles: string[], textArg: string): string { /** Builds the matching util.styleText import or require replacement. */ function createImportReplacement(statement: SgNode): string { - if (statement.kind() === 'import_statement') { - return 'import { styleText } from "node:util";'; - } + const statementKind = statement.kind(); - if (statement.kind() === 'variable_declarator') { - if (statement.field('value')?.kind() === 'await_expression') { - return '{ styleText } = await import("node:util")'; - } + switch (statementKind) { + case 'import_statement': + return 'import { styleText } from "node:util";'; + case 'variable_declarator': { + if (statement.field('value')?.kind() === 'await_expression') { + return '{ styleText } = await import("node:util")'; + } - return '{ styleText } = require("node:util")'; + return '{ styleText } = require("node:util")'; + } } return ''; diff --git a/recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/expected.mjs b/recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/expected.mjs new file mode 100644 index 00000000..aacabd1c --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/expected.mjs @@ -0,0 +1,3 @@ +import("colors/safe").then(({ green }) => { + console.log(green("Success message")); +}); diff --git a/recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/input.mjs b/recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/input.mjs new file mode 100644 index 00000000..aacabd1c --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/input.mjs @@ -0,0 +1,3 @@ +import("colors/safe").then(({ green }) => { + console.log(green("Success message")); +}); From cc82be9d1f2289fab79289db1f3eb331c9f10470 Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Thu, 28 May 2026 21:29:47 +0200 Subject: [PATCH 4/5] Clarify colors destructuring review Signed-off-by: Herrtian <70463940+Herrtian@users.noreply.github.com> --- .../colors-to-util-styletext/src/workflow.ts | 89 ++++++++++--------- .../safe-dynamic-import-await/expected.mjs | 4 + .../tests/safe-dynamic-import-await/input.mjs | 4 + 3 files changed, 55 insertions(+), 42 deletions(-) create mode 100644 recipes/colors-to-util-styletext/tests/safe-dynamic-import-await/expected.mjs create mode 100644 recipes/colors-to-util-styletext/tests/safe-dynamic-import-await/input.mjs diff --git a/recipes/colors-to-util-styletext/src/workflow.ts b/recipes/colors-to-util-styletext/src/workflow.ts index d562278d..c84a7b6e 100644 --- a/recipes/colors-to-util-styletext/src/workflow.ts +++ b/recipes/colors-to-util-styletext/src/workflow.ts @@ -96,7 +96,7 @@ function processStatement( edits: Edit[], isSafeImport: boolean, ): void { - const initialEditCount = edits.length; + const editCountBeforeStatement = edits.length; const destructuredNames = getDestructuredNames(statement); const blockedPrototypeBases = new Set(); @@ -115,7 +115,7 @@ function processStatement( processPrototypeStyles(rootNode, edits, blockedPrototypeBases); } - if (edits.length > initialEditCount) { + if (edits.length > editCountBeforeStatement) { const importReplacement = createImportReplacement(statement); if (importReplacement) { @@ -143,60 +143,65 @@ function getDestructuredNames( const names: Array<{ imported: string; local: string }> = []; const statementKind = statement.kind(); - if (statementKind === 'import_statement') { - const namedImports = statement.find({ - rule: { kind: 'named_imports' }, - }); + switch (statementKind) { + case 'import_statement': { + const namedImports = statement.find({ + rule: { kind: 'named_imports' }, + }); - if (!namedImports) return names; + if (!namedImports) break; - const importSpecifiers = namedImports.findAll({ - rule: { kind: 'import_specifier' }, - }); + const importSpecifiers = namedImports.findAll({ + rule: { kind: 'import_specifier' }, + }); - for (const specifier of importSpecifiers) { - const importedName = specifier.field('name'); - const alias = specifier.field('alias'); + for (const specifier of importSpecifiers) { + const importedName = specifier.field('name'); + const alias = specifier.field('alias'); - if (importedName) { - const imported = importedName.text(); - names.push({ imported, local: alias ? alias.text() : imported }); + if (importedName) { + const imported = importedName.text(); + names.push({ imported, local: alias ? alias.text() : imported }); + } } + break; } - } else if (statementKind === 'variable_declarator') { - const nameField = statement.field('name'); - - if (nameField?.kind() !== 'object_pattern') return names; + case 'variable_declarator': { + const nameField = statement.field('name'); - const properties = nameField.findAll({ - rule: { - any: [ - { kind: 'shorthand_property_identifier_pattern' }, - { kind: 'pair_pattern' }, - ], - }, - }); + if (nameField?.kind() !== 'object_pattern') break; - for (const prop of properties) { - const propKind = prop.kind(); + const properties = nameField.findAll({ + rule: { + any: [ + { kind: 'shorthand_property_identifier_pattern' }, + { kind: 'pair_pattern' }, + ], + }, + }); - switch (propKind) { - case 'shorthand_property_identifier_pattern': { - const name = prop.text(); + for (const prop of properties) { + const propKind = prop.kind(); - names.push({ imported: name, local: name }); - break; - } - case 'pair_pattern': { - const key = prop.field('key'); - const value = prop.field('value'); + switch (propKind) { + case 'shorthand_property_identifier_pattern': { + const name = prop.text(); - if (key && value) { - names.push({ imported: key.text(), local: value.text() }); + names.push({ imported: name, local: name }); + break; + } + case 'pair_pattern': { + const key = prop.field('key'); + const value = prop.field('value'); + + if (key && value) { + names.push({ imported: key.text(), local: value.text() }); + } + break; } - break; } } + break; } } diff --git a/recipes/colors-to-util-styletext/tests/safe-dynamic-import-await/expected.mjs b/recipes/colors-to-util-styletext/tests/safe-dynamic-import-await/expected.mjs new file mode 100644 index 00000000..4a457864 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/safe-dynamic-import-await/expected.mjs @@ -0,0 +1,4 @@ +const { styleText } = await import("node:util"); + +console.log(styleText("red", "Error message")); +console.log(styleText("green", "Success message")); diff --git a/recipes/colors-to-util-styletext/tests/safe-dynamic-import-await/input.mjs b/recipes/colors-to-util-styletext/tests/safe-dynamic-import-await/input.mjs new file mode 100644 index 00000000..ab50b3a9 --- /dev/null +++ b/recipes/colors-to-util-styletext/tests/safe-dynamic-import-await/input.mjs @@ -0,0 +1,4 @@ +const { red, green: success } = await import("colors/safe"); + +console.log(red("Error message")); +console.log(success("Success message")); From 9d49ffcf93cf8528b1f9d45cbef799e656c696aa Mon Sep 17 00:00:00 2001 From: Herrtian <70463940+Herrtian@users.noreply.github.com> Date: Fri, 29 May 2026 19:47:33 +0200 Subject: [PATCH 5/5] fix(colors): address safe import follow-ups --- recipes/colors-to-util-styletext/README.md | 8 - recipes/colors-to-util-styletext/codemod.yaml | 4 +- .../colors-to-util-styletext/src/workflow.ts | 145 +++++++++++++++++- .../safe-dynamic-import-then/expected.mjs | 4 +- .../tests/safe-unrelated-call/expected.js | 2 +- .../tests/side-effect-import/expected.mjs | 1 + .../tests/side-effect-import/input.mjs | 1 + 7 files changed, 145 insertions(+), 20 deletions(-) diff --git a/recipes/colors-to-util-styletext/README.md b/recipes/colors-to-util-styletext/README.md index 37ef1211..0dfd3a90 100644 --- a/recipes/colors-to-util-styletext/README.md +++ b/recipes/colors-to-util-styletext/README.md @@ -25,14 +25,6 @@ This recipe migrates compatible `colors` package usage to Node.js built-in `util + console.log(styleText('green', 'Success message')); ``` -## Usage - -Run this codemod with: - -```sh -npx codemod nodejs/colors-to-util-styletext -``` - ## Compatibility - Removes the `colors` dependency from package.json automatically. diff --git a/recipes/colors-to-util-styletext/codemod.yaml b/recipes/colors-to-util-styletext/codemod.yaml index 8e8b7a44..ef0889f4 100644 --- a/recipes/colors-to-util-styletext/codemod.yaml +++ b/recipes/colors-to-util-styletext/codemod.yaml @@ -17,9 +17,9 @@ targets: - typescript keywords: - - transformation - - migration - nodejs + - colors + - styleText registry: access: public diff --git a/recipes/colors-to-util-styletext/src/workflow.ts b/recipes/colors-to-util-styletext/src/workflow.ts index c84a7b6e..5013da97 100644 --- a/recipes/colors-to-util-styletext/src/workflow.ts +++ b/recipes/colors-to-util-styletext/src/workflow.ts @@ -73,13 +73,24 @@ export default function transform(root: SgRoot): string | null { const colorsStatements = getModuleDependencies(root, colorsModule); const safeColorsStatements = getModuleDependencies(root, safeColorsModule); + const safeThenCalls = getSafeDynamicImportThenCalls(rootNode); - if (!colorsStatements.length && !safeColorsStatements.length) return null; + if ( + !colorsStatements.length && + !safeColorsStatements.length && + !safeThenCalls.length + ) { + return null; + } for (const statement of safeColorsStatements) { processStatement(rootNode, statement, edits, true); } + for (const thenCall of safeThenCalls) { + processDynamicImportThenCall(thenCall, edits); + } + for (const statement of colorsStatements) { processStatement(rootNode, statement, edits, false); } @@ -121,6 +132,11 @@ function processStatement( if (importReplacement) { edits.push(statement.replace(importReplacement)); } + } else if ( + isSafeImport && + ['import_statement', 'variable_declarator'].includes(statement.kind()) + ) { + edits.push(removeUnusedSafeImport(statement)); } } @@ -262,6 +278,36 @@ function processDestructuredSafeCalls( } } +/** Rewrites colors/safe usages inside dynamic import .then callbacks. */ +function processDynamicImportThenCall( + thenCall: SgNode, + edits: Edit[], +): void { + const callback = getCallArguments(thenCall)[0]; + + if (!callback) return; + + const destructuredParam = callback.find({ + rule: { kind: 'object_pattern' }, + }); + + if (!destructuredParam) return; + + const destructuredNames = getNamesFromObjectPattern(destructuredParam); + const editCountBeforeCall = edits.length; + + processDestructuredSafeCalls(callback, destructuredNames, edits); + + if (edits.length > editCountBeforeCall) { + const dynamicImport = thenCall.field('function')?.field('object'); + + if (dynamicImport) { + edits.push(dynamicImport.replace('import("node:util")')); + } + edits.push(destructuredParam.replace('{ styleText }')); + } +} + /** Rewrites calls accessed through a colors/safe namespace binding. */ function processSafeNamespaceCalls( rootNode: SgNode, @@ -429,15 +475,16 @@ function hasUnsupportedStyles(styles: string[]): boolean { /** Returns the first real argument for a colors/safe call. */ function getFirstCallArgument(call: SgNode): string | null { - const args = call.field('arguments'); - - if (!args) return null; + return getCallArguments(call)[0]?.text() ?? null; +} - const argsList = args.children().filter((child) => child.isNamed()); +/** Returns named call arguments. */ +function getCallArguments(call: SgNode): Array> { + const args = call.field('arguments'); - if (argsList.length === 0) return null; + if (!args) return []; - return argsList[0].text(); + return args.children().filter((child) => child.isNamed()); } /** Builds a util.styleText call for one or more styles. */ @@ -468,6 +515,90 @@ function createImportReplacement(statement: SgNode): string { return ''; } +/** Finds `import("colors/safe").then(...)` calls. */ +function getSafeDynamicImportThenCalls(rootNode: SgNode): Array> { + return rootNode + .findAll({ + rule: { kind: 'call_expression' }, + }) + .filter((call) => { + const functionExpr = call.field('function'); + + if (functionExpr?.kind() !== 'member_expression') return false; + + const object = functionExpr.field('object'); + const property = functionExpr.field('property'); + + return property?.text() === 'then' && isSafeDynamicImport(object); + }); +} + +/** Checks for `import("colors/safe")`. */ +function isSafeDynamicImport(node: SgNode | null): boolean { + if (node?.kind() !== 'call_expression') return false; + + const functionExpr = node.field('function'); + const moduleName = getFirstCallArgument(node); + + return ( + functionExpr?.text() === 'import' && + (moduleName === '"colors/safe"' || moduleName === "'colors/safe'") + ); +} + +/** Removes an unused colors/safe dependency statement. */ +function removeUnusedSafeImport(statement: SgNode): Edit { + const parent = statement.parent(); + + if ( + parent && + ['lexical_declaration', 'variable_declaration'].includes(parent.kind()) + ) { + return parent.replace(''); + } + + return statement.replace(''); +} + +/** Returns destructured names from an object pattern node. */ +function getNamesFromObjectPattern( + objectPattern: SgNode, +): Array<{ imported: string; local: string }> { + const names: Array<{ imported: string; local: string }> = []; + const properties = objectPattern.findAll({ + rule: { + any: [ + { kind: 'shorthand_property_identifier_pattern' }, + { kind: 'pair_pattern' }, + ], + }, + }); + + for (const prop of properties) { + const propKind = prop.kind(); + + switch (propKind) { + case 'shorthand_property_identifier_pattern': { + const name = prop.text(); + + names.push({ imported: name, local: name }); + break; + } + case 'pair_pattern': { + const key = prop.field('key'); + const value = prop.field('value'); + + if (key && value) { + names.push({ imported: key.text(), local: value.text() }); + } + break; + } + } + } + + return names; +} + /** Skips intermediate members so only the full style chain is replaced. */ function isNestedStyleChain(node: SgNode): boolean { const parent = node.parent(); diff --git a/recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/expected.mjs b/recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/expected.mjs index aacabd1c..7464a125 100644 --- a/recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/expected.mjs +++ b/recipes/colors-to-util-styletext/tests/safe-dynamic-import-then/expected.mjs @@ -1,3 +1,3 @@ -import("colors/safe").then(({ green }) => { - console.log(green("Success message")); +import("node:util").then(({ styleText }) => { + console.log(styleText("green", "Success message")); }); diff --git a/recipes/colors-to-util-styletext/tests/safe-unrelated-call/expected.js b/recipes/colors-to-util-styletext/tests/safe-unrelated-call/expected.js index b2f68913..3f207a1c 100644 --- a/recipes/colors-to-util-styletext/tests/safe-unrelated-call/expected.js +++ b/recipes/colors-to-util-styletext/tests/safe-unrelated-call/expected.js @@ -1,3 +1,3 @@ -const colors = require("colors/safe"); + console.log("plain message"); diff --git a/recipes/colors-to-util-styletext/tests/side-effect-import/expected.mjs b/recipes/colors-to-util-styletext/tests/side-effect-import/expected.mjs index c3bf37c5..df48c12f 100644 --- a/recipes/colors-to-util-styletext/tests/side-effect-import/expected.mjs +++ b/recipes/colors-to-util-styletext/tests/side-effect-import/expected.mjs @@ -1,3 +1,4 @@ import { styleText } from "node:util"; console.log(styleText("red", "Error message")); +console.log(styleText(["yellow", "bold"], "Warning message")); diff --git a/recipes/colors-to-util-styletext/tests/side-effect-import/input.mjs b/recipes/colors-to-util-styletext/tests/side-effect-import/input.mjs index 3bfc9627..74ce239f 100644 --- a/recipes/colors-to-util-styletext/tests/side-effect-import/input.mjs +++ b/recipes/colors-to-util-styletext/tests/side-effect-import/input.mjs @@ -1,3 +1,4 @@ import "colors"; console.log("Error message".red); +console.log("Warning message".yellow.bold);