From e603f32a084889d1c47e96063b5c0aec5eaa30ac Mon Sep 17 00:00:00 2001 From: Shamya Date: Tue, 2 Jun 2026 14:14:22 -0400 Subject: [PATCH 1/8] feat: add ansi-colors to styleText codemod --- .../ast-grep/ansi-colors-to-styletext.test.ts | 191 +++++++++ .../src/ast-grep/ansi-colors-to-styletext.ts | 392 ++++++++++++++++++ 2 files changed, 583 insertions(+) create mode 100644 utils/src/ast-grep/ansi-colors-to-styletext.test.ts create mode 100644 utils/src/ast-grep/ansi-colors-to-styletext.ts diff --git a/utils/src/ast-grep/ansi-colors-to-styletext.test.ts b/utils/src/ast-grep/ansi-colors-to-styletext.test.ts new file mode 100644 index 00000000..84864318 --- /dev/null +++ b/utils/src/ast-grep/ansi-colors-to-styletext.test.ts @@ -0,0 +1,191 @@ +import assert from 'node:assert/strict'; +import { describe, it } from 'node:test'; +import astGrep from '@ast-grep/napi'; +import dedent from 'dedent'; +import process from 'node:process'; +import transform from './ansi-colors-to-styletext.ts'; + +function runTransform(code: string) { + const ast = astGrep.parse(astGrep.Lang.JavaScript, code); + return transform(ast); +} + +describe('ansi-colors-to-styletext', () => { + it('should transform CommonJS require statements', () => { + const code = dedent` + const ac = require('ansi-colors'); + console.log(ac.red('text')); + `; + + const result = runTransform(code); + + assert.strictEqual( + result, + dedent` + const { styleText } = require('node:util'); + console.log(styleText('red', 'text')); + `, + ); + }); + + it('should transform ES module imports', () => { + const code = dedent` + import ac from 'ansi-colors'; + console.log(ac.red('text')); + `; + + const result = runTransform(code); + + assert.strictEqual( + result, + dedent` + import { styleText } from 'node:util'; + console.log(styleText('red', 'text')); + `, + ); + }); + + it('should transform namespace imports', () => { + const code = dedent` + import * as ac from 'ansi-colors'; + console.log(ac.bold.red('text')); + `; + + const result = runTransform(code); + + assert.strictEqual( + result, + dedent` + import { styleText } from 'node:util'; + console.log(styleText(['bold', 'red'], 'text')); + `, + ); + }); + + it('should transform dynamic imports', () => { + const code = dedent` + const ac = await import('ansi-colors'); + console.log(ac.red('text')); + `; + + const result = runTransform(code); + + assert.strictEqual( + result, + dedent` + const { styleText } = await import('node:util'); + console.log(styleText('red', 'text')); + `, + ); + }); + + it('should transform simple style calls', () => { + const code = dedent` + const ac = require('ansi-colors'); + const output = ac.green('hello'); + `; + + const result = runTransform(code); + + assert.strictEqual( + result, + dedent` + const { styleText } = require('node:util'); + const output = styleText('green', 'hello'); + `, + ); + }); + + it('should transform chained style calls', () => { + const code = dedent` + const ac = require('ansi-colors'); + const output = ac.bold.red('hello'); + `; + + const result = runTransform(code); + + assert.strictEqual( + result, + dedent` + const { styleText } = require('node:util'); + const output = styleText(['bold', 'red'], 'hello'); + `, + ); + }); + + it('should warn and skip unsupported ansi-colors APIs', () => { + const code = dedent` + const ac = require('ansi-colors'); + if (true) { + ac.enabled; + console.log(ac.red('hello')); + } + `; + + const warnings: string[] = []; + const originalWrite = process.stderr.write; + process.stderr.write = ((chunk: unknown) => { + warnings.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + try { + const result = runTransform(code); + assert.strictEqual( + result, + dedent` + const { styleText } = require('node:util'); + if (true) { + ac.enabled; + console.log(ac.red('hello')); + } + `, + ); + assert.equal(warnings.length, 1); + assert.match( + warnings[0], + /uses ansi-colors API 'enabled' that does not have any equivalent in util\.styleText please review this line/, + ); + } finally { + process.stderr.write = originalWrite; + }; + }); + + it('should warn and skip nested unsupported ansi-colors APIs', () => { + const code = dedent` + const ac = require('ansi-colors'); + function demo() { + ac.bold.visible('hello'); + } + console.log(ac.red('outside')); + `; + + const warnings: string[] = []; + const originalWrite = process.stderr.write; + process.stderr.write = ((chunk: unknown) => { + warnings.push(String(chunk)); + return true; + }) as typeof process.stderr.write; + + try { + const result = runTransform(code); + assert.strictEqual( + result, + dedent` + const { styleText } = require('node:util'); + function demo() { + ac.bold.visible('hello'); + } + console.log(styleText('red', 'outside')); + `, + ); + assert.equal(warnings.length, 1); + assert.match( + warnings[0], + /uses ansi-colors API 'visible' that does not have any equivalent in util\.styleText please review this line/, + ); + } finally { + process.stderr.write = originalWrite; + }; + }); +}); \ No newline at end of file diff --git a/utils/src/ast-grep/ansi-colors-to-styletext.ts b/utils/src/ast-grep/ansi-colors-to-styletext.ts new file mode 100644 index 00000000..0e812a17 --- /dev/null +++ b/utils/src/ast-grep/ansi-colors-to-styletext.ts @@ -0,0 +1,392 @@ +import { getScope } from './get-scope.ts'; +import { + getNodeImportCalls, + getNodeImportStatements, +} from './import-statement.ts'; +import { + getNodeRequireCalls, + getRequireNamespaceIdentifier, +} from './require-call.ts'; +import process from 'node:process'; +import type { Edit, Range, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; +import type Js from '@codemod.com/jssg-types/langs/javascript'; + +const MODULE_NAME = 'ansi-colors'; +const STYLE_MODULE = 'node:util'; +const STYLE_FUNCTION = 'styleText'; + +const COMPATIBILITY_MAP: Record = { + overline: 'overlined', +}; + +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', + 'italic', + 'underline', + 'strikethrough', + 'hidden', + 'dim', + 'overlined', + 'blink', + 'inverse', + 'doubleunderline', + 'framed', +]); + +const UNSUPPORTED_APIS = new Set([ + 'enabled', + 'visible', + 'unstyle', + 'alias', + 'theme', + 'create', +]); + +type StatementInfo = { + statement: SgNode; + binding: string; +}; + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + const skippedRanges: Range[] = []; + const statements = getAnsiColorsStatements(root); + + if (!statements.length) return null; + + for (const info of statements) { + const unsupportedRanges = getUnsupportedScopes(rootNode, info.binding); + skippedRanges.push(...unsupportedRanges); + + const importEdit = createStyleTextImportEdit(info.statement); + if (importEdit && !isRangeWithinAny(importEdit.range, skippedRanges)) { + edits.push(importEdit.edit); + } + } + + for (const info of statements) { + const callEdits = getStyleCallEdits(rootNode, info.binding, skippedRanges); + edits.push(...callEdits); + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} + +function getAnsiColorsStatements(root: SgRoot): StatementInfo[] { + const statements: StatementInfo[] = []; + + for (const statement of getNodeImportStatements(root, MODULE_NAME)) { + const binding = getDefaultOrNamespaceImport(statement); + if (binding) { + statements.push({ statement, binding: binding.text() }); + } + } + + for (const statement of getNodeRequireCalls(root, MODULE_NAME)) { + const binding = getRequireNamespaceIdentifier(statement); + if (binding) { + statements.push({ statement, binding: binding.text() }); + } + } + + for (const statement of getNodeImportCalls(root, MODULE_NAME)) { + const binding = getDynamicImportBinding(statement); + if (binding) { + statements.push({ statement, binding: binding.text() }); + } + } + + return statements; +} + +function getDefaultOrNamespaceImport(statement: SgNode): SgNode | null { + const defaultImport = statement.find({ + rule: { + kind: 'identifier', + inside: { + kind: 'import_clause', + not: { + any: [ + { inside: { kind: 'named_imports' } }, + { inside: { kind: 'namespace_import' } }, + ], + }, + }, + }, + }); + + if (defaultImport) return defaultImport; + + return statement.find({ + rule: { + kind: 'identifier', + inside: { + kind: 'namespace_import', + }, + }, + }); +} + +function getDynamicImportBinding(statement: SgNode): SgNode | null { + const binding = statement.find({ + rule: { + kind: 'identifier', + inside: { + kind: 'variable_declarator', + not: { + inside: { + kind: 'object_pattern', + }, + }, + }, + }, + }); + + return binding ?? null; +} + +function createStyleTextImportEdit(statement: SgNode): { edit: Edit; range: Range } | null { + if (statement.kind() === 'import_statement') { + return { + edit: statement.replace(`import { ${STYLE_FUNCTION} } from '${STYLE_MODULE}';`), + range: statement.range(), + }; + } + + const declaration = statement.parent(); + if (!declaration || declaration.kind() !== 'lexical_declaration') return null; + + const value = statement.field('value'); + if (value?.kind() !== 'await_expression') { + return { + edit: declaration.replace(`const { ${STYLE_FUNCTION} } = require('${STYLE_MODULE}');`), + range: declaration.range(), + }; + } + + return { + edit: declaration.replace(`const { ${STYLE_FUNCTION} } = await import('${STYLE_MODULE}');`), + range: declaration.range(), + }; +} + +function getStyleCallEdits( + rootNode: SgNode, + binding: string, + skippedRanges: Range[], +): Edit[] { + const callEdits: Edit[] = []; + const calls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of calls) { + if (isRangeWithinAny(call.range(), skippedRanges)) continue; + + const functionNode = call.field('function'); + if (!functionNode || functionNode.kind() !== 'member_expression') continue; + + const styles = extractStyles(functionNode, binding); + if (!styles || !styles.length) continue; + + const textArg = getFirstCallArgument(call); + if (!textArg) continue; + + callEdits.push(call.replace(createStyleTextReplacement(styles, textArg))); + } + + return callEdits; +} + +function extractStyles(node: SgNode, binding: string): string[] | null { + const objectNode = node.field('object'); + const propertyNode = node.field('property'); + + if (!objectNode || !propertyNode || propertyNode.kind() !== 'property_identifier') { + return null; + } + + const propertyName = normalizeStyleName(propertyNode.text()); + if (objectNode.kind() === 'identifier') { + if (objectNode.text() !== binding) return null; + if (!SUPPORTED_STYLES.has(propertyName)) return null; + return [propertyName]; + } + + if (objectNode.kind() === 'member_expression') { + const nested = extractStyles(objectNode, binding); + if (!nested) return null; + if (!SUPPORTED_STYLES.has(propertyName)) return null; + return [...nested, propertyName]; + } + + return null; +} + +function normalizeStyleName(styleName: string): string { + return COMPATIBILITY_MAP[styleName] ?? styleName; +} + +function getUnsupportedScopes(rootNode: SgNode, binding: string): Range[] { + const unsupportedScopes = new Map(); + const memberExpressions = rootNode.findAll({ + rule: { + kind: 'member_expression', + }, + }); + + for (const memberExpression of memberExpressions) { + if (!isTopLevelChain(memberExpression, binding)) continue; + + const propertyNames = collectPropertyNames(memberExpression, binding); + if (!propertyNames) continue; + + for (const propertyName of propertyNames) { + if (!UNSUPPORTED_APIS.has(propertyName)) continue; + warnUnsupportedApi(memberExpression.getRoot(), memberExpression, propertyName); + const scope = getScope(memberExpression); + if (scope) { + unsupportedScopes.set( + `${scope.range().start.index}:${scope.range().end.index}`, + scope.range(), + ); + } + break; + } + } + + return [...unsupportedScopes.values()]; +} + +function isTopLevelChain(node: SgNode, binding: string): boolean { + if (!isRootedAtBinding(node, binding)) return false; + + const parent = node.parent(); + if (!parent || parent.kind() !== 'member_expression') return true; + + return !isRootedAtBinding(parent, binding); +} + +function collectPropertyNames(node: SgNode, binding: string): string[] | null { + const objectNode = node.field('object'); + const propertyNode = node.field('property'); + + if (!objectNode || !propertyNode || propertyNode.kind() !== 'property_identifier') { + return null; + } + + if (objectNode.kind() === 'identifier') { + if (objectNode.text() !== binding) return null; + return [propertyNode.text()]; + } + + if (objectNode.kind() === 'member_expression') { + const nested = collectPropertyNames(objectNode, binding); + if (!nested) return null; + return [...nested, propertyNode.text()]; + } + + return null; +} + +function isRootedAtBinding(node: SgNode, binding: string): boolean { + const objectNode = node.field('object'); + const propertyNode = node.field('property'); + + if (!objectNode || !propertyNode || propertyNode.kind() !== 'property_identifier') { + return false; + } + + if (objectNode.kind() === 'identifier') { + return objectNode.text() === binding; + } + + if (objectNode.kind() === 'member_expression') { + return isRootedAtBinding(objectNode, binding); + } + + return false; +} + +function warnUnsupportedApi( + rootNode: SgRoot, + node: SgNode, + api: string, +) { + const filename = rootNode.filename(); + const { start } = node.range(); + const message = `${filename}:${start.line}:${start.column}: uses ansi-colors API '${api}' that does not have any equivalent in util.styleText please review this line`; + + process.stderr.write(`${message}\n`); +} + +function getFirstCallArgument(call: SgNode): string | null { + const args = call.field('arguments'); + if (!args) return null; + + const children = args.children().filter((child) => ![',', '(', ')'].includes(child.kind())); + if (!children.length) return null; + + return children[0].text(); +} + +function createStyleTextReplacement(styles: string[], textArg: string): string { + if (styles.length === 1) { + return `${STYLE_FUNCTION}('${styles[0]}', ${textArg})`; + } + + return `${STYLE_FUNCTION}([${styles.map((style) => `'${style}'`).join(', ')}], ${textArg})`; +} + +function isRangeWithinAny(inner: Range, outers: Range[]): boolean { + return outers.some( + (outer) => + inner.start.index >= outer.start.index && inner.end.index <= outer.end.index, + ); +} \ No newline at end of file From 1e0672be3afc0a8eab2114ec956b55fc28dc5df3 Mon Sep 17 00:00:00 2001 From: Shamya Date: Wed, 3 Jun 2026 16:16:42 -0400 Subject: [PATCH 2/8] feat: add ansi-colors-to-styletext codemod --- recipes/ansi-colors-to-styletext/README.md | 24 ++ recipes/ansi-colors-to-styletext/codemod.yaml | 25 ++ recipes/ansi-colors-to-styletext/package.json | 24 ++ .../ansi-colors-to-styletext/src/workflow.ts | 256 ++++++++++++++++++ .../tests/basic-color/expected.js | 3 + .../tests/basic-color/input.js | 3 + .../tests/chained-styles/expected.js | 4 + .../tests/chained-styles/input.js | 4 + .../tests/esm-default-import/expected.js | 4 + .../tests/esm-default-import/input.js | 4 + .../tests/gray-alias/expected.js | 4 + .../tests/gray-alias/input.js | 4 + .../tests/multiple-uses/expected.js | 5 + .../tests/multiple-uses/input.js | 5 + .../tests/no-match/expected.js | 3 + .../tests/no-match/input.js | 3 + .../tests/reusable-functions/expected.js | 3 + .../tests/reusable-functions/input.js | 3 + .../tests/string-concatenation/expected.js | 4 + .../tests/string-concatenation/input.js | 4 + .../tests/template-literals/expected.js | 6 + .../tests/template-literals/input.js | 6 + .../tests/unsupported-api/expected.js | 4 + .../tests/unsupported-api/input.js | 4 + .../tests/unsupported-warnings/expected.js | 8 + .../tests/unsupported-warnings/input.js | 8 + .../tests/with-import/expected.js | 5 + .../tests/with-import/input.js | 5 + .../ansi-colors-to-styletext/workflow.yaml | 25 ++ 29 files changed, 460 insertions(+) create mode 100644 recipes/ansi-colors-to-styletext/README.md create mode 100644 recipes/ansi-colors-to-styletext/codemod.yaml create mode 100644 recipes/ansi-colors-to-styletext/package.json create mode 100644 recipes/ansi-colors-to-styletext/src/workflow.ts create mode 100644 recipes/ansi-colors-to-styletext/tests/basic-color/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/basic-color/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/chained-styles/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/chained-styles/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/esm-default-import/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/esm-default-import/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/gray-alias/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/gray-alias/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/multiple-uses/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/multiple-uses/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/no-match/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/no-match/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/reusable-functions/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/reusable-functions/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/string-concatenation/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/string-concatenation/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/template-literals/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/template-literals/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-api/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-api/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-warnings/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/with-import/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/with-import/input.js create mode 100644 recipes/ansi-colors-to-styletext/workflow.yaml diff --git a/recipes/ansi-colors-to-styletext/README.md b/recipes/ansi-colors-to-styletext/README.md new file mode 100644 index 00000000..01beffa9 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/README.md @@ -0,0 +1,24 @@ +# ansi-colors to util.styleText + +Migrates `ansi-colors` usage to Node.js's built-in `util.styleText` API. Requires Node.js v20.12 or later. + +## Example + +Before: +```js +const ac = require('ansi-colors'); +console.log(ac.red('Error message')); +``` + +After: +```js +const { styleText } = require('node:util'); +console.log(styleText('red', 'Error message')); +``` + +## What gets transformed + +- Default imports — `ac.red(text)` → `styleText('red', text)` +- Destructured imports — `const { red } = require('ansi-colors')` → `const { styleText } = require('node:util')` +- ESM imports — `import ac from 'ansi-colors'` → `import { styleText } from 'node:util'` +- Chained styles — `ac.bold.red(text)` → `styleText(['bold', 'red'], text)` \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/codemod.yaml b/recipes/ansi-colors-to-styletext/codemod.yaml new file mode 100644 index 00000000..bdce9895 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/codemod.yaml @@ -0,0 +1,25 @@ +schema_version: "1.0" +name: "@nodejs/ansi-colors-to-styletext" +version: 1.0.0 +description: "Migrate from ansi-colors package to Node.js util.styleText API" +author: Shamya Haria +license: MIT +workflow: workflow.yaml +category: migration +repository: https://github.com/nodejs/userland-migrations + +targets: + languages: + - javascript + - typescript + +keywords: + - transformation + - migration + - nodejs + - ansi-colors + - styletext + +registry: + access: public + visibility: public diff --git a/recipes/ansi-colors-to-styletext/package.json b/recipes/ansi-colors-to-styletext/package.json new file mode 100644 index 00000000..7abbab7b --- /dev/null +++ b/recipes/ansi-colors-to-styletext/package.json @@ -0,0 +1,24 @@ +{ + "name": "@nodejs/ansi-colors-to-styletext", + "version": "1.0.0", + "description": "Migrate from ansi-colors package to Node.js util.styleText API", + "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/ansi-colors-to-styletext", + "bugs": "https://github.com/nodejs/userland-migrations/issues" + }, + "author": "Shamya Haria", + "license": "MIT", + "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/ansi-colors-to-styletext/README.md", + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.1" + }, + "dependencies": { + "@nodejs/codemod-utils": "*" + } +} diff --git a/recipes/ansi-colors-to-styletext/src/workflow.ts b/recipes/ansi-colors-to-styletext/src/workflow.ts new file mode 100644 index 00000000..372d53c5 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/src/workflow.ts @@ -0,0 +1,256 @@ +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'; +import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; + +const ANSI_COLORS_BINDING = 'ansi-colors'; + +const SUPPORTED_METHODS = 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', 'overline', 'blink', 'doubleunderline', 'framed', +]); + +const COMPATIBILITY_MAP: Record = { + gray: 'blackBright', + grey: 'blackBright', +}; + +const UNSUPPORTED_API_WARNINGS: Record = { + enabled: `util.styleText has no equivalent runtime instance flag. Map this configuration to environment variables instead: set process.env.NO_COLOR='1' or NODE_DISABLE_COLORS='1' before application initialization.`, + visible: `util.styleText lacks a visual toggling mechanism and will always return a string wrapper. Please guard the call site explicitly: const out = visible ? styleText('red', msg) : '';`, + unstyle: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, + stripColor: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, + hasAnsi: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, + hasColor: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, + alias: `util.styleText is stateless and does not maintain a style or theme registry. Migrate global configurations to dedicated structural objects mapping keys to arrow functions (e.g., const theme = { error: (m) => styleText(['bold', 'red'], m) }).`, + theme: `util.styleText is stateless and does not maintain a style or theme registry. Migrate global configurations to dedicated structural objects mapping keys to arrow functions (e.g., const theme = { error: (m) => styleText(['bold', 'red'], m) }).`, + create: `util.styleText is stateless and does not maintain a style or theme registry. Migrate global configurations to dedicated structural objects mapping keys to arrow functions (e.g., const theme = { error: (m) => styleText(['bold', 'red'], m) }).`, + define: `util.styleText is stateless and does not maintain a style or theme registry. Migrate global configurations to dedicated structural objects mapping keys to arrow functions (e.g., const theme = { error: (m) => styleText(['bold', 'red'], m) }).`, +}; + +const UNSUPPORTED_APIS = new Set(Object.keys(UNSUPPORTED_API_WARNINGS)); + +export default function transform(root: SgRoot): string | null { + const rootNode = root.root(); + const edits: Edit[] = []; + const statements = getModuleDependencies(root, ANSI_COLORS_BINDING); + + if (!statements.length) return null; + + for (const statement of statements) { + const initialEditCount = edits.length; + const destructuredNames = getDestructuredNames(statement); + + if (destructuredNames.length > 0) { + processDestructuredImports(rootNode, destructuredNames, edits); + } else { + const binding = resolveBindingPath(statement, '$'); + if (binding) { + checkUnsupportedApis(rootNode, binding, root); + processDefaultImports(rootNode, binding, edits); + } + } + + if (edits.length > initialEditCount) { + const importReplacement = createImportReplacement(statement); + if (importReplacement) { + edits.push(statement.replace(importReplacement)); + } + } + } + + if (!edits.length) return null; + + return rootNode.commitEdits(edits); +} + +function normalizeStyle(style: string): string { + return COMPATIBILITY_MAP[style] ?? style; +} + +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 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) { + for (const specifier of namedImports.findAll({ rule: { kind: 'import_specifier' } })) { + const importedName = specifier.field('name'); + const alias = specifier.field('alias'); + + if (importedName) { + const imported = importedName.text(); + const local = alias ? alias.text() : imported; + if (SUPPORTED_METHODS.has(imported)) { + names.push({ imported: normalizeStyle(imported), local }); + } + } + } + } + } else if (statement.kind() === 'variable_declarator') { + const nameField = statement.field('name'); + + if (nameField && nameField.kind() === 'object_pattern') { + 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(); + if (SUPPORTED_METHODS.has(name)) { + names.push({ imported: normalizeStyle(name), local: name }); + } + } else if (prop.kind() === 'pair_pattern') { + const key = prop.field('key'); + const value = prop.field('value'); + if (key && value) { + const imported = key.text(); + const local = value.text(); + if (SUPPORTED_METHODS.has(imported)) { + names.push({ imported: normalizeStyle(imported), local }); + } + } + } + } + } + } + + return names; +} + +function extractChainedStyles(node: SgNode, binding: string): string[] | null { + const objectNode = node.field('object'); + const propertyNode = node.field('property'); + + if (!objectNode || !propertyNode || propertyNode.kind() !== 'property_identifier') { + return null; + } + + const propertyName = normalizeStyle(propertyNode.text()); + + if (objectNode.kind() === 'identifier') { + if (objectNode.text() !== binding) return null; + if (!SUPPORTED_METHODS.has(propertyNode.text()) && !COMPATIBILITY_MAP[propertyNode.text()]) return null; + return [propertyName]; + } + + if (objectNode.kind() === 'member_expression') { + const nested = extractChainedStyles(objectNode, binding); + if (!nested) return null; + if (!SUPPORTED_METHODS.has(propertyNode.text()) && !COMPATIBILITY_MAP[propertyNode.text()]) return null; + return [...nested, propertyName]; + } + + return null; +} + +function checkUnsupportedApis(rootNode: SgNode, binding: string, root: SgRoot): void { + const memberExpressions = rootNode.findAll({ + rule: { kind: 'member_expression' }, + }); + + for (const memberExpr of memberExpressions) { + const objectNode = memberExpr.field('object'); + const propertyNode = memberExpr.field('property'); + + if (!objectNode || !propertyNode) continue; + if (objectNode.text() !== binding) continue; + if (propertyNode.kind() !== 'property_identifier') continue; + + const propertyName = propertyNode.text(); + if (!UNSUPPORTED_APIS.has(propertyName)) continue; + + const filename = root.filename(); + const { start } = memberExpr.range(); + const message = UNSUPPORTED_API_WARNINGS[propertyName]; + console.warn(`${filename}:${start.line}:${start.column}: ${message}`); + } +} + +function processDestructuredImports( + rootNode: SgNode, + destructuredNames: Array<{ imported: string; local: string }>, + edits: Edit[], +): void { + for (const { local, imported } of destructuredNames) { + const calls = rootNode.findAll({ + rule: { + kind: 'call_expression', + pattern: `${local}($$$ARGS)`, + }, + }); + + for (const call of calls) { + const args = call.field('arguments'); + if (args) { + edits.push(call.replace(`styleText('${imported}', ${args.text().slice(1, -1)})`)); + } + } + } +} + +function processDefaultImports( + rootNode: SgNode, + binding: string, + edits: Edit[], +): void { + const calls = rootNode.findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, + }, + }); + + for (const call of calls) { + const functionNode = call.field('function'); + if (!functionNode || functionNode.kind() !== 'member_expression') continue; + + const styles = extractChainedStyles(functionNode, binding); + if (!styles || !styles.length) continue; + + const args = call.field('arguments'); + if (!args) continue; + + const textArg = args.text().slice(1, -1); + + if (styles.length === 1) { + edits.push(call.replace(`styleText('${styles[0]}', ${textArg})`)); + } else { + const styleArray = styles.map(s => `'${s}'`).join(', '); + edits.push(call.replace(`styleText([${styleArray}], ${textArg})`)); + } + } +} \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/basic-color/expected.js b/recipes/ansi-colors-to-styletext/tests/basic-color/expected.js new file mode 100644 index 00000000..45cb524e --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/basic-color/expected.js @@ -0,0 +1,3 @@ +const { styleText } = require('node:util'); + +console.log(styleText('red', 'Error message')); diff --git a/recipes/ansi-colors-to-styletext/tests/basic-color/input.js b/recipes/ansi-colors-to-styletext/tests/basic-color/input.js new file mode 100644 index 00000000..50f48cf6 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/basic-color/input.js @@ -0,0 +1,3 @@ +const ansi = require('ansi-colors'); + +console.log(ansi.red('Error message')); diff --git a/recipes/ansi-colors-to-styletext/tests/chained-styles/expected.js b/recipes/ansi-colors-to-styletext/tests/chained-styles/expected.js new file mode 100644 index 00000000..5cb97e89 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/chained-styles/expected.js @@ -0,0 +1,4 @@ +const { styleText } = require('node:util'); + +console.log(styleText(['bold', 'red'], 'Critical error')); +console.log(styleText(['bgBlue', 'white', 'bold'], 'HEADER')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/chained-styles/input.js b/recipes/ansi-colors-to-styletext/tests/chained-styles/input.js new file mode 100644 index 00000000..f7a8061b --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/chained-styles/input.js @@ -0,0 +1,4 @@ +const ac = require('ansi-colors'); + +console.log(ac.bold.red('Critical error')); +console.log(ac.bgBlue.white.bold('HEADER')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/esm-default-import/expected.js b/recipes/ansi-colors-to-styletext/tests/esm-default-import/expected.js new file mode 100644 index 00000000..3b597123 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/esm-default-import/expected.js @@ -0,0 +1,4 @@ +import { styleText } from 'node:util'; + +console.log(styleText('red', 'Error')); +console.log(styleText(['bold', 'green'], 'Success')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/esm-default-import/input.js b/recipes/ansi-colors-to-styletext/tests/esm-default-import/input.js new file mode 100644 index 00000000..838d2868 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/esm-default-import/input.js @@ -0,0 +1,4 @@ +import ac from 'ansi-colors'; + +console.log(ac.red('Error')); +console.log(ac.bold.green('Success')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/gray-alias/expected.js b/recipes/ansi-colors-to-styletext/tests/gray-alias/expected.js new file mode 100644 index 00000000..7d5059bf --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/gray-alias/expected.js @@ -0,0 +1,4 @@ +const { styleText } = require('node:util'); + +console.log(styleText('blackBright', 'Hint text')); +console.log(styleText('blackBright', 'Another hint')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/gray-alias/input.js b/recipes/ansi-colors-to-styletext/tests/gray-alias/input.js new file mode 100644 index 00000000..d7a49025 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/gray-alias/input.js @@ -0,0 +1,4 @@ +const ac = require('ansi-colors'); + +console.log(ac.gray('Hint text')); +console.log(ac.grey('Another hint')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/multiple-uses/expected.js b/recipes/ansi-colors-to-styletext/tests/multiple-uses/expected.js new file mode 100644 index 00000000..8774adc7 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/multiple-uses/expected.js @@ -0,0 +1,5 @@ +const { styleText } = require('node:util'); + +console.log(styleText('red', 'Error')); +console.log(styleText('green', 'Success')); +console.log(styleText('blue', 'Info')); diff --git a/recipes/ansi-colors-to-styletext/tests/multiple-uses/input.js b/recipes/ansi-colors-to-styletext/tests/multiple-uses/input.js new file mode 100644 index 00000000..b290699b --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/multiple-uses/input.js @@ -0,0 +1,5 @@ +const ansi = require('ansi-colors'); + +console.log(ansi.red('Error')); +console.log(ansi.green('Success')); +console.log(ansi.blue('Info')); diff --git a/recipes/ansi-colors-to-styletext/tests/no-match/expected.js b/recipes/ansi-colors-to-styletext/tests/no-match/expected.js new file mode 100644 index 00000000..271b3129 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/no-match/expected.js @@ -0,0 +1,3 @@ +const chalk = require('chalk'); + +console.log(chalk.red('text')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/no-match/input.js b/recipes/ansi-colors-to-styletext/tests/no-match/input.js new file mode 100644 index 00000000..271b3129 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/no-match/input.js @@ -0,0 +1,3 @@ +const chalk = require('chalk'); + +console.log(chalk.red('text')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/reusable-functions/expected.js b/recipes/ansi-colors-to-styletext/tests/reusable-functions/expected.js new file mode 100644 index 00000000..c005ca59 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/reusable-functions/expected.js @@ -0,0 +1,3 @@ +const { styleText } = require('node:util'); +const errorStyle = (msg) => styleText(['bold', 'red'], msg); +const status = level === 'error' ? styleText(['bold', 'red'], 'boom') : styleText('yellow', 'slow'); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/reusable-functions/input.js b/recipes/ansi-colors-to-styletext/tests/reusable-functions/input.js new file mode 100644 index 00000000..cbea1ae5 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/reusable-functions/input.js @@ -0,0 +1,3 @@ +const ac = require('ansi-colors'); +const errorStyle = (msg) => ac.bold.red(msg); +const status = level === 'error' ? ac.bold.red('boom') : ac.yellow('slow'); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/string-concatenation/expected.js b/recipes/ansi-colors-to-styletext/tests/string-concatenation/expected.js new file mode 100644 index 00000000..288996b7 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/string-concatenation/expected.js @@ -0,0 +1,4 @@ +const { styleText } = require('node:util'); + +console.log('Hello, ' + styleText('green', 'World') + '!'); +console.log(styleText(['bgRedBright', 'white'], ' ERR ') + ' ' + styleText('red', 'File not found')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/string-concatenation/input.js b/recipes/ansi-colors-to-styletext/tests/string-concatenation/input.js new file mode 100644 index 00000000..3641b676 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/string-concatenation/input.js @@ -0,0 +1,4 @@ +const ac = require('ansi-colors'); + +console.log('Hello, ' + ac.green('World') + '!'); +console.log(ac.bgRedBright.white(' ERR ') + ' ' + ac.red('File not found')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/template-literals/expected.js b/recipes/ansi-colors-to-styletext/tests/template-literals/expected.js new file mode 100644 index 00000000..8c4cfdea --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/template-literals/expected.js @@ -0,0 +1,6 @@ +const { styleText } = require('node:util'); +const file = 'server.js'; +const line = '42'; + +console.log(`${styleText(['bold', 'red'], '[ERR]')} ${styleText('dim', file)}:${styleText('dim', line)}`); +console.log(`Multi-badge: ${styleText(['bgRed', 'white'], ' ERR ')} ${styleText(['bgGreen', 'black'], ' OK ')}`); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/template-literals/input.js b/recipes/ansi-colors-to-styletext/tests/template-literals/input.js new file mode 100644 index 00000000..59771b8f --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/template-literals/input.js @@ -0,0 +1,6 @@ +const ac = require('ansi-colors'); +const file = 'server.js'; +const line = '42'; + +console.log(`${ac.bold.red('[ERR]')} ${ac.dim(file)}:${ac.dim(line)}`); +console.log(`Multi-badge: ${ac.bgRed.white(' ERR ')} ${ac.bgGreen.black(' OK ')}`); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-api/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-api/expected.js new file mode 100644 index 00000000..366c814b --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-api/expected.js @@ -0,0 +1,4 @@ +const { styleText } = require('node:util'); +ac.enabled = false; + +console.log(styleText('red', 'text')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-api/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-api/input.js new file mode 100644 index 00000000..fe8b9e9b --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-api/input.js @@ -0,0 +1,4 @@ +const ac = require('ansi-colors'); +ac.enabled = false; + +console.log(ac.red('text')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js new file mode 100644 index 00000000..215a57fb --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js @@ -0,0 +1,8 @@ +const { styleText } = require('node:util'); +ac.enabled = false; +ac.visible = false; +ac.unstyle('some text'); +ac.alias('error', ac.bold.red); +ac.theme({ error: ac.bold.red }); + +console.log(styleText('red', 'text')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/input.js new file mode 100644 index 00000000..5723f132 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/input.js @@ -0,0 +1,8 @@ +const ac = require('ansi-colors'); +ac.enabled = false; +ac.visible = false; +ac.unstyle('some text'); +ac.alias('error', ac.bold.red); +ac.theme({ error: ac.bold.red }); + +console.log(ac.red('text')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/with-import/expected.js b/recipes/ansi-colors-to-styletext/tests/with-import/expected.js new file mode 100644 index 00000000..7e778034 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/with-import/expected.js @@ -0,0 +1,5 @@ +import { styleText } from 'node:util'; + +const error = styleText('red', 'Error'); +const success = styleText('green', 'Success'); +const important = styleText('bold', 'Important'); diff --git a/recipes/ansi-colors-to-styletext/tests/with-import/input.js b/recipes/ansi-colors-to-styletext/tests/with-import/input.js new file mode 100644 index 00000000..e75e039a --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/with-import/input.js @@ -0,0 +1,5 @@ +import { red, green, bold } from 'ansi-colors'; + +const error = red('Error'); +const success = green('Success'); +const important = bold('Important'); diff --git a/recipes/ansi-colors-to-styletext/workflow.yaml b/recipes/ansi-colors-to-styletext/workflow.yaml new file mode 100644 index 00000000..7b01a841 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/workflow.yaml @@ -0,0 +1,25 @@ +version: "1" + +nodes: + - id: apply-transforms + name: Apply AST Transformations + type: automatic + runtime: + type: direct + steps: + - name: "Replace ansi-colors with util.styleText" + js-ast-grep: + js_file: src/workflow.ts + base_path: . + include: + - "**/*.js" + - "**/*.jsx" + - "**/*.mjs" + - "**/*.cjs" + - "**/*.cts" + - "**/*.mts" + - "**/*.ts" + - "**/*.tsx" + exclude: + - "**/node_modules/**" + language: typescript From d2cab9240d04b3b351d01f06a287db0dd0249149 Mon Sep 17 00:00:00 2001 From: Shamya Date: Wed, 3 Jun 2026 16:30:17 -0400 Subject: [PATCH 3/8] feat: add ansi-colors-to-styletext codemod --- recipes/ansi-colors-to-styletext/package.json | 4 ++- .../src/remove-dependencies.ts | 13 +++++++++ .../remove-ansi-colors/expected.json | 10 +++++++ .../remove-ansi-colors/input.json | 12 +++++++++ .../ansi-colors-to-styletext/workflow.yaml | 27 +++++++++++++++---- 5 files changed, 60 insertions(+), 6 deletions(-) create mode 100644 recipes/ansi-colors-to-styletext/src/remove-dependencies.ts create mode 100644 recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/expected.json create mode 100644 recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/input.json diff --git a/recipes/ansi-colors-to-styletext/package.json b/recipes/ansi-colors-to-styletext/package.json index 7abbab7b..53fbde32 100644 --- a/recipes/ansi-colors-to-styletext/package.json +++ b/recipes/ansi-colors-to-styletext/package.json @@ -4,7 +4,9 @@ "description": "Migrate from ansi-colors package to Node.js util.styleText API", "type": "module", "scripts": { - "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests" + "test": "npx codemod jssg test -l typescript ./src/workflow.ts ./tests && npx codemod jssg test -l json ./src/remove-dependencies.ts ./tests/remove-dependencies --allow-child-process --allow-fs --strictness cst", + "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", diff --git a/recipes/ansi-colors-to-styletext/src/remove-dependencies.ts b/recipes/ansi-colors-to-styletext/src/remove-dependencies.ts new file mode 100644 index 00000000..d0457493 --- /dev/null +++ b/recipes/ansi-colors-to-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(['ansi-colors', '@types/ansi-colors'], { + packageJsonPath: root.filename(), + runInstall: false, + persistFileWrite: false, + }); +}; + +export default transform; \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/expected.json b/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/expected.json new file mode 100644 index 00000000..33d25829 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/expected.json @@ -0,0 +1,10 @@ +{ + "name": "fixture", + "version": "1.0.0", + "dependencies": { + "express": "^4.18.2" + }, + "devDependencies": { + "typescript": "^5.6.0" + } +} \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/input.json b/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/input.json new file mode 100644 index 00000000..490b36b8 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/remove-dependencies/remove-ansi-colors/input.json @@ -0,0 +1,12 @@ +{ + "name": "fixture", + "version": "1.0.0", + "dependencies": { + "ansi-colors": "^4.1.3", + "express": "^4.18.2" + }, + "devDependencies": { + "@types/ansi-colors": "^1.0.0", + "typescript": "^5.6.0" + } +} \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/workflow.yaml b/recipes/ansi-colors-to-styletext/workflow.yaml index 7b01a841..e68a1534 100644 --- a/recipes/ansi-colors-to-styletext/workflow.yaml +++ b/recipes/ansi-colors-to-styletext/workflow.yaml @@ -1,25 +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 - runtime: - type: direct steps: - - name: "Replace ansi-colors with util.styleText" + - name: Migrate from ansi-colors to Node.js built-in util.styleText API js-ast-grep: js_file: src/workflow.ts base_path: . include: + - "**/*.cjs" + - "**/*.cts" - "**/*.js" - "**/*.jsx" - "**/*.mjs" - - "**/*.cjs" - - "**/*.cts" - "**/*.mts" - "**/*.ts" - "**/*.tsx" exclude: - "**/node_modules/**" language: typescript + + - id: remove-dependencies + name: Remove ansi-colors dependency + type: automatic + steps: + - name: Detect package manager and remove ansi-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 \ No newline at end of file From 067c6319437c7973ad7d748462a23457d6f85b5c Mon Sep 17 00:00:00 2001 From: Shamya Date: Thu, 4 Jun 2026 18:52:31 -0400 Subject: [PATCH 4/8] fix(adopt): address review feedback on ansi-colors-to-styletext --- package-lock.json | 15 + recipes/ansi-colors-to-styletext/README.md | 56 ++- recipes/ansi-colors-to-styletext/codemod.yaml | 3 + .../ansi-colors-to-styletext/src/workflow.ts | 115 +++-- .../tests/commonjs-destructured/expected.js | 5 + .../tests/commonjs-destructured/input.js | 5 + .../tests/dynamic-import-await/expected.js | 4 + .../tests/dynamic-import-await/input.js | 4 + .../tests/dynamic-import-then/expected.js | 4 + .../tests/dynamic-import-then/input.js | 4 + .../mixed-chained-destructured/expected.js | 5 + .../tests/mixed-chained-destructured/input.js | 5 + .../ast-grep/ansi-colors-to-styletext.test.ts | 191 --------- .../src/ast-grep/ansi-colors-to-styletext.ts | 392 ------------------ utils/src/ast-grep/update-binding.ts | 2 +- 15 files changed, 179 insertions(+), 631 deletions(-) create mode 100644 recipes/ansi-colors-to-styletext/tests/commonjs-destructured/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/commonjs-destructured/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/dynamic-import-await/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/dynamic-import-await/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/dynamic-import-then/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/dynamic-import-then/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/input.js delete mode 100644 utils/src/ast-grep/ansi-colors-to-styletext.test.ts delete mode 100644 utils/src/ast-grep/ansi-colors-to-styletext.ts diff --git a/package-lock.json b/package-lock.json index 2a6b9e59..902b8e53 100644 --- a/package-lock.json +++ b/package-lock.json @@ -514,6 +514,10 @@ "dev": true, "license": "Apache-2.0" }, + "node_modules/@nodejs/ansi-colors-to-styletext": { + "resolved": "recipes/ansi-colors-to-styletext", + "link": true + }, "node_modules/@nodejs/axios-to-whatwg-fetch": { "resolved": "recipes/axios-to-whatwg-fetch", "link": true @@ -824,6 +828,17 @@ "dev": true, "license": "MIT" }, + "recipes/ansi-colors-to-styletext": { + "name": "@nodejs/ansi-colors-to-styletext", + "version": "1.0.0", + "license": "MIT", + "dependencies": { + "@nodejs/codemod-utils": "*" + }, + "devDependencies": { + "@codemod.com/jssg-types": "^1.6.1" + } + }, "recipes/axios-to-whatwg-fetch": { "name": "@nodejs/axios-to-whatwg-fetch", "version": "1.0.0", diff --git a/recipes/ansi-colors-to-styletext/README.md b/recipes/ansi-colors-to-styletext/README.md index 01beffa9..f1711e34 100644 --- a/recipes/ansi-colors-to-styletext/README.md +++ b/recipes/ansi-colors-to-styletext/README.md @@ -1,24 +1,50 @@ # ansi-colors to util.styleText -Migrates `ansi-colors` usage to Node.js's built-in `util.styleText` API. Requires Node.js v20.12 or later. +This recipe migrates from the external `ansi-colors` package to Node.js's built-in `util.styleText` API. It transforms ansi-colors method calls to use the native Node.js styling functionality. -## Example +## Examples -Before: -```js -const ac = require('ansi-colors'); -console.log(ac.red('Error message')); +```diff +- import ac from 'ansi-colors'; ++ import { styleText } from 'node:util'; +- console.log(ac.red('Error message')); ++ console.log(styleText('red', 'Error message')); +- console.log(ac.green('Success message')); ++ console.log(styleText('green', 'Success message')); ``` -After: -```js -const { styleText } = require('node:util'); -console.log(styleText('red', 'Error message')); +```diff +- const ac = require('ansi-colors'); ++ const { styleText } = require('node:util'); +- console.log(ac.bold.red('Critical error')); ++ console.log(styleText(['bold', 'red'], 'Critical error')); ``` -## What gets transformed +```diff +- const { red, blue } = require('ansi-colors'); ++ const { styleText } = require('node:util'); +- console.log(red('Error')); ++ console.log(styleText('red', 'Error')); +- console.log(blue('Info')); ++ console.log(styleText('blue', 'Info')); +``` + +## Usage + +Run this codemod with: + +```sh +npx codemod @nodejs/ansi-colors-to-styletext +``` + +## Compatibility + +- **Removes ansi-colors dependency** from package.json automatically +- **Supports all ansi-colors methods**: colors, background colors, text modifiers, and chained styles +- **Unsupported methods**: `enabled`, `visible`, `unstyle`, `alias`, `theme`, `create` (warnings will be shown) + +## Limitations -- Default imports — `ac.red(text)` → `styleText('red', text)` -- Destructured imports — `const { red } = require('ansi-colors')` → `const { styleText } = require('node:util')` -- ESM imports — `import ac from 'ansi-colors'` → `import { styleText } from 'node:util'` -- Chained styles — `ac.bold.red(text)` → `styleText(['bold', 'red'], text)` \ No newline at end of file +- **Runtime toggles** like `ac.enabled = false` require manual intervention +- **Custom themes and aliases** need to be rewritten as plain objects +- **Dynamic imports with `.then()`** are not transformed and require manual migration \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/codemod.yaml b/recipes/ansi-colors-to-styletext/codemod.yaml index bdce9895..c21d49af 100644 --- a/recipes/ansi-colors-to-styletext/codemod.yaml +++ b/recipes/ansi-colors-to-styletext/codemod.yaml @@ -1,6 +1,9 @@ schema_version: "1.0" name: "@nodejs/ansi-colors-to-styletext" version: 1.0.0 +capabilities: + - fs + - child_process description: "Migrate from ansi-colors package to Node.js util.styleText API" author: Shamya Haria license: MIT diff --git a/recipes/ansi-colors-to-styletext/src/workflow.ts b/recipes/ansi-colors-to-styletext/src/workflow.ts index 372d53c5..8bcb8b7f 100644 --- a/recipes/ansi-colors-to-styletext/src/workflow.ts +++ b/recipes/ansi-colors-to-styletext/src/workflow.ts @@ -1,21 +1,9 @@ -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'; import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dependencies'; const ANSI_COLORS_BINDING = 'ansi-colors'; -const SUPPORTED_METHODS = 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', 'overline', 'blink', 'doubleunderline', 'framed', -]); - const COMPATIBILITY_MAP: Record = { gray: 'blackBright', grey: 'blackBright', @@ -50,7 +38,7 @@ export default function transform(root: SgRoot): string | null { if (destructuredNames.length > 0) { processDestructuredImports(rootNode, destructuredNames, edits); } else { - const binding = resolveBindingPath(statement, '$'); + const binding = getDefaultBinding(statement); if (binding) { checkUnsupportedApis(rootNode, binding, root); processDefaultImports(rootNode, binding, edits); @@ -70,10 +58,18 @@ export default function transform(root: SgRoot): string | null { return rootNode.commitEdits(edits); } +/** + * Normalizes a style name using the compatibility map. + * e.g. gray -> blackBright + */ function normalizeStyle(style: string): string { return COMPATIBILITY_MAP[style] ?? style; } +/** + * Creates the replacement import statement for util.styleText + * based on the kind of the original import statement. + */ function createImportReplacement(statement: SgNode): string { if (statement.kind() === 'import_statement') { return `import { styleText } from 'node:util';`; @@ -89,6 +85,50 @@ function createImportReplacement(statement: SgNode): string { return ''; } +/** + * Extracts the default or namespace binding name from an import statement. + * e.g. import ac from 'ansi-colors' -> 'ac' + * e.g. const ac = require('ansi-colors') -> 'ac' + */ +function getDefaultBinding(statement: SgNode): string | null { + if (statement.kind() === 'import_statement') { + const defaultImport = statement.find({ + rule: { + kind: 'identifier', + inside: { + kind: 'import_clause', + not: { + any: [ + { has: { kind: 'named_imports' } }, + { has: { kind: 'namespace_import' } }, + ], + }, + }, + }, + }); + if (defaultImport) return defaultImport.text(); + + const namespaceImport = statement.find({ + rule: { + kind: 'identifier', + inside: { kind: 'namespace_import' }, + }, + }); + return namespaceImport?.text() ?? null; + } + + if (statement.kind() === 'variable_declarator') { + const nameField = statement.field('name'); + if (nameField?.kind() === 'identifier') return nameField.text(); + } + + return null; +} + +/** + * Extracts destructured import names from a statement. + * Handles both ESM named imports and CommonJS destructured requires. + */ function getDestructuredNames( statement: SgNode, ): Array<{ imported: string; local: string }> { @@ -105,16 +145,14 @@ function getDestructuredNames( if (importedName) { const imported = importedName.text(); const local = alias ? alias.text() : imported; - if (SUPPORTED_METHODS.has(imported)) { - names.push({ imported: normalizeStyle(imported), local }); - } + names.push({ imported: normalizeStyle(imported), local }); } } } } else if (statement.kind() === 'variable_declarator') { const nameField = statement.field('name'); - if (nameField && nameField.kind() === 'object_pattern') { + if (nameField?.kind() === 'object_pattern') { const properties = nameField.findAll({ rule: { any: [ @@ -127,18 +165,12 @@ function getDestructuredNames( for (const prop of properties) { if (prop.kind() === 'shorthand_property_identifier_pattern') { const name = prop.text(); - if (SUPPORTED_METHODS.has(name)) { - names.push({ imported: normalizeStyle(name), local: name }); - } + names.push({ imported: normalizeStyle(name), local: name }); } else if (prop.kind() === 'pair_pattern') { const key = prop.field('key'); const value = prop.field('value'); if (key && value) { - const imported = key.text(); - const local = value.text(); - if (SUPPORTED_METHODS.has(imported)) { - names.push({ imported: normalizeStyle(imported), local }); - } + names.push({ imported: normalizeStyle(key.text()), local: value.text() }); } } } @@ -148,6 +180,11 @@ function getDestructuredNames( return names; } +/** + * Recursively extracts chained style names from a member expression. + * e.g. ac.bold.red -> ['bold', 'red'] + * Returns null if the chain does not originate from the expected binding. + */ function extractChainedStyles(node: SgNode, binding: string): string[] | null { const objectNode = node.field('object'); const propertyNode = node.field('property'); @@ -156,24 +193,30 @@ function extractChainedStyles(node: SgNode, binding: string): string[] | nul return null; } - const propertyName = normalizeStyle(propertyNode.text()); + const propertyName = propertyNode.text(); + + if (UNSUPPORTED_APIS.has(propertyName)) return null; + + const normalizedName = normalizeStyle(propertyName); if (objectNode.kind() === 'identifier') { if (objectNode.text() !== binding) return null; - if (!SUPPORTED_METHODS.has(propertyNode.text()) && !COMPATIBILITY_MAP[propertyNode.text()]) return null; - return [propertyName]; + return [normalizedName]; } if (objectNode.kind() === 'member_expression') { const nested = extractChainedStyles(objectNode, binding); if (!nested) return null; - if (!SUPPORTED_METHODS.has(propertyNode.text()) && !COMPATIBILITY_MAP[propertyNode.text()]) return null; - return [...nested, propertyName]; + return [...nested, normalizedName]; } return null; } +/** + * Checks for unsupported ansi-colors APIs and emits specific warnings + * with guidance on how to handle each case manually. + */ function checkUnsupportedApis(rootNode: SgNode, binding: string, root: SgRoot): void { const memberExpressions = rootNode.findAll({ rule: { kind: 'member_expression' }, @@ -197,6 +240,10 @@ function checkUnsupportedApis(rootNode: SgNode, binding: string, root: SgRoo } } +/** + * Processes destructured imports and replaces each call with styleText. + * e.g. const { red } = require('ansi-colors'); red('text') -> styleText('red', 'text') + */ function processDestructuredImports( rootNode: SgNode, destructuredNames: Array<{ imported: string; local: string }>, @@ -219,6 +266,10 @@ function processDestructuredImports( } } +/** + * Processes default/namespace imports and replaces chained member calls with styleText. + * e.g. ac.bold.red('text') -> styleText(['bold', 'red'], 'text') + */ function processDefaultImports( rootNode: SgNode, binding: string, @@ -236,10 +287,10 @@ function processDefaultImports( for (const call of calls) { const functionNode = call.field('function'); - if (!functionNode || functionNode.kind() !== 'member_expression') continue; + if (functionNode?.kind() !== 'member_expression') continue; const styles = extractChainedStyles(functionNode, binding); - if (!styles || !styles.length) continue; + if (!styles?.length) continue; const args = call.field('arguments'); if (!args) continue; diff --git a/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/expected.js b/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/expected.js new file mode 100644 index 00000000..3b36aa2f --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/expected.js @@ -0,0 +1,5 @@ +const { styleText } = require('node:util'); + +console.log(styleText('red', 'Error')); +console.log(styleText('blue', 'Info')); +console.log(styleText('bold', 'Important')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/input.js b/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/input.js new file mode 100644 index 00000000..d6dcf16a --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/commonjs-destructured/input.js @@ -0,0 +1,5 @@ +const { red, blue, bold } = require('ansi-colors'); + +console.log(red('Error')); +console.log(blue('Info')); +console.log(bold('Important')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/expected.js b/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/expected.js new file mode 100644 index 00000000..aacd4619 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/expected.js @@ -0,0 +1,4 @@ +const { styleText } = await import('node:util'); + +console.log(styleText('red', 'Error')); +console.log(styleText(['bold', 'green'], 'Success')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/input.js b/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/input.js new file mode 100644 index 00000000..5b0b431a --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/dynamic-import-await/input.js @@ -0,0 +1,4 @@ +const ac = await import('ansi-colors'); + +console.log(ac.red('Error')); +console.log(ac.bold.green('Success')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/expected.js b/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/expected.js new file mode 100644 index 00000000..5896a512 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/expected.js @@ -0,0 +1,4 @@ +import('ansi-colors').then((ac) => { + console.log(ac.red('Error')); + console.log(ac.bold.green('Success')); +}); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/input.js b/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/input.js new file mode 100644 index 00000000..5896a512 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/dynamic-import-then/input.js @@ -0,0 +1,4 @@ +import('ansi-colors').then((ac) => { + console.log(ac.red('Error')); + console.log(ac.bold.green('Success')); +}); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/expected.js b/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/expected.js new file mode 100644 index 00000000..5a87241b --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/expected.js @@ -0,0 +1,5 @@ +import { styleText } from 'node:util'; +import { styleText } from 'node:util'; + +console.log(styleText(['bold', 'blue'], 'Header')); +console.log(styleText('red', 'Error')); \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/input.js b/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/input.js new file mode 100644 index 00000000..b2071b8d --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/mixed-chained-destructured/input.js @@ -0,0 +1,5 @@ +import ac from 'ansi-colors'; +import { red } from 'ansi-colors'; + +console.log(ac.bold.blue('Header')); +console.log(red('Error')); \ No newline at end of file diff --git a/utils/src/ast-grep/ansi-colors-to-styletext.test.ts b/utils/src/ast-grep/ansi-colors-to-styletext.test.ts deleted file mode 100644 index 84864318..00000000 --- a/utils/src/ast-grep/ansi-colors-to-styletext.test.ts +++ /dev/null @@ -1,191 +0,0 @@ -import assert from 'node:assert/strict'; -import { describe, it } from 'node:test'; -import astGrep from '@ast-grep/napi'; -import dedent from 'dedent'; -import process from 'node:process'; -import transform from './ansi-colors-to-styletext.ts'; - -function runTransform(code: string) { - const ast = astGrep.parse(astGrep.Lang.JavaScript, code); - return transform(ast); -} - -describe('ansi-colors-to-styletext', () => { - it('should transform CommonJS require statements', () => { - const code = dedent` - const ac = require('ansi-colors'); - console.log(ac.red('text')); - `; - - const result = runTransform(code); - - assert.strictEqual( - result, - dedent` - const { styleText } = require('node:util'); - console.log(styleText('red', 'text')); - `, - ); - }); - - it('should transform ES module imports', () => { - const code = dedent` - import ac from 'ansi-colors'; - console.log(ac.red('text')); - `; - - const result = runTransform(code); - - assert.strictEqual( - result, - dedent` - import { styleText } from 'node:util'; - console.log(styleText('red', 'text')); - `, - ); - }); - - it('should transform namespace imports', () => { - const code = dedent` - import * as ac from 'ansi-colors'; - console.log(ac.bold.red('text')); - `; - - const result = runTransform(code); - - assert.strictEqual( - result, - dedent` - import { styleText } from 'node:util'; - console.log(styleText(['bold', 'red'], 'text')); - `, - ); - }); - - it('should transform dynamic imports', () => { - const code = dedent` - const ac = await import('ansi-colors'); - console.log(ac.red('text')); - `; - - const result = runTransform(code); - - assert.strictEqual( - result, - dedent` - const { styleText } = await import('node:util'); - console.log(styleText('red', 'text')); - `, - ); - }); - - it('should transform simple style calls', () => { - const code = dedent` - const ac = require('ansi-colors'); - const output = ac.green('hello'); - `; - - const result = runTransform(code); - - assert.strictEqual( - result, - dedent` - const { styleText } = require('node:util'); - const output = styleText('green', 'hello'); - `, - ); - }); - - it('should transform chained style calls', () => { - const code = dedent` - const ac = require('ansi-colors'); - const output = ac.bold.red('hello'); - `; - - const result = runTransform(code); - - assert.strictEqual( - result, - dedent` - const { styleText } = require('node:util'); - const output = styleText(['bold', 'red'], 'hello'); - `, - ); - }); - - it('should warn and skip unsupported ansi-colors APIs', () => { - const code = dedent` - const ac = require('ansi-colors'); - if (true) { - ac.enabled; - console.log(ac.red('hello')); - } - `; - - const warnings: string[] = []; - const originalWrite = process.stderr.write; - process.stderr.write = ((chunk: unknown) => { - warnings.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - try { - const result = runTransform(code); - assert.strictEqual( - result, - dedent` - const { styleText } = require('node:util'); - if (true) { - ac.enabled; - console.log(ac.red('hello')); - } - `, - ); - assert.equal(warnings.length, 1); - assert.match( - warnings[0], - /uses ansi-colors API 'enabled' that does not have any equivalent in util\.styleText please review this line/, - ); - } finally { - process.stderr.write = originalWrite; - }; - }); - - it('should warn and skip nested unsupported ansi-colors APIs', () => { - const code = dedent` - const ac = require('ansi-colors'); - function demo() { - ac.bold.visible('hello'); - } - console.log(ac.red('outside')); - `; - - const warnings: string[] = []; - const originalWrite = process.stderr.write; - process.stderr.write = ((chunk: unknown) => { - warnings.push(String(chunk)); - return true; - }) as typeof process.stderr.write; - - try { - const result = runTransform(code); - assert.strictEqual( - result, - dedent` - const { styleText } = require('node:util'); - function demo() { - ac.bold.visible('hello'); - } - console.log(styleText('red', 'outside')); - `, - ); - assert.equal(warnings.length, 1); - assert.match( - warnings[0], - /uses ansi-colors API 'visible' that does not have any equivalent in util\.styleText please review this line/, - ); - } finally { - process.stderr.write = originalWrite; - }; - }); -}); \ No newline at end of file diff --git a/utils/src/ast-grep/ansi-colors-to-styletext.ts b/utils/src/ast-grep/ansi-colors-to-styletext.ts deleted file mode 100644 index 0e812a17..00000000 --- a/utils/src/ast-grep/ansi-colors-to-styletext.ts +++ /dev/null @@ -1,392 +0,0 @@ -import { getScope } from './get-scope.ts'; -import { - getNodeImportCalls, - getNodeImportStatements, -} from './import-statement.ts'; -import { - getNodeRequireCalls, - getRequireNamespaceIdentifier, -} from './require-call.ts'; -import process from 'node:process'; -import type { Edit, Range, SgNode, SgRoot } from '@codemod.com/jssg-types/main'; -import type Js from '@codemod.com/jssg-types/langs/javascript'; - -const MODULE_NAME = 'ansi-colors'; -const STYLE_MODULE = 'node:util'; -const STYLE_FUNCTION = 'styleText'; - -const COMPATIBILITY_MAP: Record = { - overline: 'overlined', -}; - -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', - 'italic', - 'underline', - 'strikethrough', - 'hidden', - 'dim', - 'overlined', - 'blink', - 'inverse', - 'doubleunderline', - 'framed', -]); - -const UNSUPPORTED_APIS = new Set([ - 'enabled', - 'visible', - 'unstyle', - 'alias', - 'theme', - 'create', -]); - -type StatementInfo = { - statement: SgNode; - binding: string; -}; - -export default function transform(root: SgRoot): string | null { - const rootNode = root.root(); - const edits: Edit[] = []; - const skippedRanges: Range[] = []; - const statements = getAnsiColorsStatements(root); - - if (!statements.length) return null; - - for (const info of statements) { - const unsupportedRanges = getUnsupportedScopes(rootNode, info.binding); - skippedRanges.push(...unsupportedRanges); - - const importEdit = createStyleTextImportEdit(info.statement); - if (importEdit && !isRangeWithinAny(importEdit.range, skippedRanges)) { - edits.push(importEdit.edit); - } - } - - for (const info of statements) { - const callEdits = getStyleCallEdits(rootNode, info.binding, skippedRanges); - edits.push(...callEdits); - } - - if (!edits.length) return null; - - return rootNode.commitEdits(edits); -} - -function getAnsiColorsStatements(root: SgRoot): StatementInfo[] { - const statements: StatementInfo[] = []; - - for (const statement of getNodeImportStatements(root, MODULE_NAME)) { - const binding = getDefaultOrNamespaceImport(statement); - if (binding) { - statements.push({ statement, binding: binding.text() }); - } - } - - for (const statement of getNodeRequireCalls(root, MODULE_NAME)) { - const binding = getRequireNamespaceIdentifier(statement); - if (binding) { - statements.push({ statement, binding: binding.text() }); - } - } - - for (const statement of getNodeImportCalls(root, MODULE_NAME)) { - const binding = getDynamicImportBinding(statement); - if (binding) { - statements.push({ statement, binding: binding.text() }); - } - } - - return statements; -} - -function getDefaultOrNamespaceImport(statement: SgNode): SgNode | null { - const defaultImport = statement.find({ - rule: { - kind: 'identifier', - inside: { - kind: 'import_clause', - not: { - any: [ - { inside: { kind: 'named_imports' } }, - { inside: { kind: 'namespace_import' } }, - ], - }, - }, - }, - }); - - if (defaultImport) return defaultImport; - - return statement.find({ - rule: { - kind: 'identifier', - inside: { - kind: 'namespace_import', - }, - }, - }); -} - -function getDynamicImportBinding(statement: SgNode): SgNode | null { - const binding = statement.find({ - rule: { - kind: 'identifier', - inside: { - kind: 'variable_declarator', - not: { - inside: { - kind: 'object_pattern', - }, - }, - }, - }, - }); - - return binding ?? null; -} - -function createStyleTextImportEdit(statement: SgNode): { edit: Edit; range: Range } | null { - if (statement.kind() === 'import_statement') { - return { - edit: statement.replace(`import { ${STYLE_FUNCTION} } from '${STYLE_MODULE}';`), - range: statement.range(), - }; - } - - const declaration = statement.parent(); - if (!declaration || declaration.kind() !== 'lexical_declaration') return null; - - const value = statement.field('value'); - if (value?.kind() !== 'await_expression') { - return { - edit: declaration.replace(`const { ${STYLE_FUNCTION} } = require('${STYLE_MODULE}');`), - range: declaration.range(), - }; - } - - return { - edit: declaration.replace(`const { ${STYLE_FUNCTION} } = await import('${STYLE_MODULE}');`), - range: declaration.range(), - }; -} - -function getStyleCallEdits( - rootNode: SgNode, - binding: string, - skippedRanges: Range[], -): Edit[] { - const callEdits: Edit[] = []; - const calls = rootNode.findAll({ - rule: { - kind: 'call_expression', - has: { - field: 'function', - kind: 'member_expression', - }, - }, - }); - - for (const call of calls) { - if (isRangeWithinAny(call.range(), skippedRanges)) continue; - - const functionNode = call.field('function'); - if (!functionNode || functionNode.kind() !== 'member_expression') continue; - - const styles = extractStyles(functionNode, binding); - if (!styles || !styles.length) continue; - - const textArg = getFirstCallArgument(call); - if (!textArg) continue; - - callEdits.push(call.replace(createStyleTextReplacement(styles, textArg))); - } - - return callEdits; -} - -function extractStyles(node: SgNode, binding: string): string[] | null { - const objectNode = node.field('object'); - const propertyNode = node.field('property'); - - if (!objectNode || !propertyNode || propertyNode.kind() !== 'property_identifier') { - return null; - } - - const propertyName = normalizeStyleName(propertyNode.text()); - if (objectNode.kind() === 'identifier') { - if (objectNode.text() !== binding) return null; - if (!SUPPORTED_STYLES.has(propertyName)) return null; - return [propertyName]; - } - - if (objectNode.kind() === 'member_expression') { - const nested = extractStyles(objectNode, binding); - if (!nested) return null; - if (!SUPPORTED_STYLES.has(propertyName)) return null; - return [...nested, propertyName]; - } - - return null; -} - -function normalizeStyleName(styleName: string): string { - return COMPATIBILITY_MAP[styleName] ?? styleName; -} - -function getUnsupportedScopes(rootNode: SgNode, binding: string): Range[] { - const unsupportedScopes = new Map(); - const memberExpressions = rootNode.findAll({ - rule: { - kind: 'member_expression', - }, - }); - - for (const memberExpression of memberExpressions) { - if (!isTopLevelChain(memberExpression, binding)) continue; - - const propertyNames = collectPropertyNames(memberExpression, binding); - if (!propertyNames) continue; - - for (const propertyName of propertyNames) { - if (!UNSUPPORTED_APIS.has(propertyName)) continue; - warnUnsupportedApi(memberExpression.getRoot(), memberExpression, propertyName); - const scope = getScope(memberExpression); - if (scope) { - unsupportedScopes.set( - `${scope.range().start.index}:${scope.range().end.index}`, - scope.range(), - ); - } - break; - } - } - - return [...unsupportedScopes.values()]; -} - -function isTopLevelChain(node: SgNode, binding: string): boolean { - if (!isRootedAtBinding(node, binding)) return false; - - const parent = node.parent(); - if (!parent || parent.kind() !== 'member_expression') return true; - - return !isRootedAtBinding(parent, binding); -} - -function collectPropertyNames(node: SgNode, binding: string): string[] | null { - const objectNode = node.field('object'); - const propertyNode = node.field('property'); - - if (!objectNode || !propertyNode || propertyNode.kind() !== 'property_identifier') { - return null; - } - - if (objectNode.kind() === 'identifier') { - if (objectNode.text() !== binding) return null; - return [propertyNode.text()]; - } - - if (objectNode.kind() === 'member_expression') { - const nested = collectPropertyNames(objectNode, binding); - if (!nested) return null; - return [...nested, propertyNode.text()]; - } - - return null; -} - -function isRootedAtBinding(node: SgNode, binding: string): boolean { - const objectNode = node.field('object'); - const propertyNode = node.field('property'); - - if (!objectNode || !propertyNode || propertyNode.kind() !== 'property_identifier') { - return false; - } - - if (objectNode.kind() === 'identifier') { - return objectNode.text() === binding; - } - - if (objectNode.kind() === 'member_expression') { - return isRootedAtBinding(objectNode, binding); - } - - return false; -} - -function warnUnsupportedApi( - rootNode: SgRoot, - node: SgNode, - api: string, -) { - const filename = rootNode.filename(); - const { start } = node.range(); - const message = `${filename}:${start.line}:${start.column}: uses ansi-colors API '${api}' that does not have any equivalent in util.styleText please review this line`; - - process.stderr.write(`${message}\n`); -} - -function getFirstCallArgument(call: SgNode): string | null { - const args = call.field('arguments'); - if (!args) return null; - - const children = args.children().filter((child) => ![',', '(', ')'].includes(child.kind())); - if (!children.length) return null; - - return children[0].text(); -} - -function createStyleTextReplacement(styles: string[], textArg: string): string { - if (styles.length === 1) { - return `${STYLE_FUNCTION}('${styles[0]}', ${textArg})`; - } - - return `${STYLE_FUNCTION}([${styles.map((style) => `'${style}'`).join(', ')}], ${textArg})`; -} - -function isRangeWithinAny(inner: Range, outers: Range[]): boolean { - return outers.some( - (outer) => - inner.start.index >= outer.start.index && inner.end.index <= outer.end.index, - ); -} \ No newline at end of file diff --git a/utils/src/ast-grep/update-binding.ts b/utils/src/ast-grep/update-binding.ts index fcde97b2..b88532b8 100644 --- a/utils/src/ast-grep/update-binding.ts +++ b/utils/src/ast-grep/update-binding.ts @@ -185,7 +185,7 @@ function handleNamedImportBindings( }, }); - if (Boolean(namespaceImport) && namespaceImport.text() === options.old) { + if (namespaceImport && namespaceImport.text() === options.old) { if (options?.new) { // Namespace imports can only be replaced with a single binding const newName = Array.isArray(options.new) ? options.new[0] : options.new; From a4b03993d1037619fd7e90520cc93745f37cf3d0 Mon Sep 17 00:00:00 2001 From: Shamya Date: Sat, 6 Jun 2026 01:20:47 -0400 Subject: [PATCH 5/8] docs(ansi-colors-to-styletext): Readme Updated --- recipes/ansi-colors-to-styletext/README.md | 8 -------- 1 file changed, 8 deletions(-) diff --git a/recipes/ansi-colors-to-styletext/README.md b/recipes/ansi-colors-to-styletext/README.md index f1711e34..1c1d7d09 100644 --- a/recipes/ansi-colors-to-styletext/README.md +++ b/recipes/ansi-colors-to-styletext/README.md @@ -29,14 +29,6 @@ This recipe migrates from the external `ansi-colors` package to Node.js's built- + console.log(styleText('blue', 'Info')); ``` -## Usage - -Run this codemod with: - -```sh -npx codemod @nodejs/ansi-colors-to-styletext -``` - ## Compatibility - **Removes ansi-colors dependency** from package.json automatically From 36eba4cb42eb037456eb8614f88dd6d62dbca903 Mon Sep 17 00:00:00 2001 From: Shamya Date: Mon, 6 Jul 2026 14:34:41 -0400 Subject: [PATCH 6/8] fix(ansi-colors-to-styletext): remove type annotations, cache kind calls, replace Set with array --- .../ansi-colors-to-styletext/src/workflow.ts | 70 ++++++++----------- .../tests/unsupported-api/expected.js | 6 +- .../tests/unsupported-api/input.js | 4 +- 3 files changed, 33 insertions(+), 47 deletions(-) diff --git a/recipes/ansi-colors-to-styletext/src/workflow.ts b/recipes/ansi-colors-to-styletext/src/workflow.ts index 8bcb8b7f..4d3d27e6 100644 --- a/recipes/ansi-colors-to-styletext/src/workflow.ts +++ b/recipes/ansi-colors-to-styletext/src/workflow.ts @@ -4,12 +4,12 @@ import { getModuleDependencies } from '@nodejs/codemod-utils/ast-grep/module-dep const ANSI_COLORS_BINDING = 'ansi-colors'; -const COMPATIBILITY_MAP: Record = { +const COMPATIBILITY_MAP = { gray: 'blackBright', grey: 'blackBright', }; -const UNSUPPORTED_API_WARNINGS: Record = { +const UNSUPPORTED_API_WARNINGS = { enabled: `util.styleText has no equivalent runtime instance flag. Map this configuration to environment variables instead: set process.env.NO_COLOR='1' or NODE_DISABLE_COLORS='1' before application initialization.`, visible: `util.styleText lacks a visual toggling mechanism and will always return a string wrapper. Please guard the call site explicitly: const out = visible ? styleText('red', msg) : '';`, unstyle: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, @@ -22,7 +22,7 @@ const UNSUPPORTED_API_WARNINGS: Record = { define: `util.styleText is stateless and does not maintain a style or theme registry. Migrate global configurations to dedicated structural objects mapping keys to arrow functions (e.g., const theme = { error: (m) => styleText(['bold', 'red'], m) }).`, }; -const UNSUPPORTED_APIS = new Set(Object.keys(UNSUPPORTED_API_WARNINGS)); +const UNSUPPORTED_APIS = Object.keys(UNSUPPORTED_API_WARNINGS); export default function transform(root: SgRoot): string | null { const rootNode = root.root(); @@ -59,23 +59,16 @@ export default function transform(root: SgRoot): string | null { } /** - * Normalizes a style name using the compatibility map. - * e.g. gray -> blackBright - */ -function normalizeStyle(style: string): string { - return COMPATIBILITY_MAP[style] ?? style; -} - -/** - * Creates the replacement import statement for util.styleText - * based on the kind of the original import statement. + * Builds the replacement import line based on whether the original was ESM, CJS, or dynamic. */ function createImportReplacement(statement: SgNode): string { - if (statement.kind() === 'import_statement') { + const kind = statement.kind(); + + if (kind === 'import_statement') { return `import { styleText } from 'node:util';`; } - if (statement.kind() === 'variable_declarator') { + if (kind === 'variable_declarator') { if (statement.field('value')?.kind() === 'await_expression') { return `{ styleText } = await import('node:util')`; } @@ -86,12 +79,12 @@ function createImportReplacement(statement: SgNode): string { } /** - * Extracts the default or namespace binding name from an import statement. - * e.g. import ac from 'ansi-colors' -> 'ac' - * e.g. const ac = require('ansi-colors') -> 'ac' + * Resolves the local binding name for default and namespace imports. */ function getDefaultBinding(statement: SgNode): string | null { - if (statement.kind() === 'import_statement') { + const kind = statement.kind(); + + if (kind === 'import_statement') { const defaultImport = statement.find({ rule: { kind: 'identifier', @@ -117,7 +110,7 @@ function getDefaultBinding(statement: SgNode): string | null { return namespaceImport?.text() ?? null; } - if (statement.kind() === 'variable_declarator') { + if (kind === 'variable_declarator') { const nameField = statement.field('name'); if (nameField?.kind() === 'identifier') return nameField.text(); } @@ -126,15 +119,15 @@ function getDefaultBinding(statement: SgNode): string | null { } /** - * Extracts destructured import names from a statement. - * Handles both ESM named imports and CommonJS destructured requires. + * Collects named import bindings from ESM and CJS destructured statements. */ function getDestructuredNames( statement: SgNode, ): Array<{ imported: string; local: string }> { const names: Array<{ imported: string; local: string }> = []; + const kind = statement.kind(); - if (statement.kind() === 'import_statement') { + if (kind === 'import_statement') { const namedImports = statement.find({ rule: { kind: 'named_imports' } }); if (namedImports) { @@ -145,11 +138,11 @@ function getDestructuredNames( if (importedName) { const imported = importedName.text(); const local = alias ? alias.text() : imported; - names.push({ imported: normalizeStyle(imported), local }); + names.push({ imported: COMPATIBILITY_MAP[imported] ?? imported, local }); } } } - } else if (statement.kind() === 'variable_declarator') { + } else if (kind === 'variable_declarator') { const nameField = statement.field('name'); if (nameField?.kind() === 'object_pattern') { @@ -165,12 +158,13 @@ function getDestructuredNames( for (const prop of properties) { if (prop.kind() === 'shorthand_property_identifier_pattern') { const name = prop.text(); - names.push({ imported: normalizeStyle(name), local: name }); + names.push({ imported: COMPATIBILITY_MAP[name] ?? 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: normalizeStyle(key.text()), local: value.text() }); + const imported = key.text(); + names.push({ imported: COMPATIBILITY_MAP[imported] ?? imported, local: value.text() }); } } } @@ -181,9 +175,8 @@ function getDestructuredNames( } /** - * Recursively extracts chained style names from a member expression. - * e.g. ac.bold.red -> ['bold', 'red'] - * Returns null if the chain does not originate from the expected binding. + * Walks a member expression chain and returns the ordered style names, + * or null if the chain doesn't start from the expected binding. */ function extractChainedStyles(node: SgNode, binding: string): string[] | null { const objectNode = node.field('object'); @@ -195,9 +188,9 @@ function extractChainedStyles(node: SgNode, binding: string): string[] | nul const propertyName = propertyNode.text(); - if (UNSUPPORTED_APIS.has(propertyName)) return null; + if (UNSUPPORTED_APIS.includes(propertyName)) return null; - const normalizedName = normalizeStyle(propertyName); + const normalizedName = COMPATIBILITY_MAP[propertyName] ?? propertyName; if (objectNode.kind() === 'identifier') { if (objectNode.text() !== binding) return null; @@ -214,8 +207,7 @@ function extractChainedStyles(node: SgNode, binding: string): string[] | nul } /** - * Checks for unsupported ansi-colors APIs and emits specific warnings - * with guidance on how to handle each case manually. + * Emits targeted warnings for ansi-colors APIs with no util.styleText equivalent. */ function checkUnsupportedApis(rootNode: SgNode, binding: string, root: SgRoot): void { const memberExpressions = rootNode.findAll({ @@ -231,18 +223,17 @@ function checkUnsupportedApis(rootNode: SgNode, binding: string, root: SgRoo if (propertyNode.kind() !== 'property_identifier') continue; const propertyName = propertyNode.text(); - if (!UNSUPPORTED_APIS.has(propertyName)) continue; + if (!UNSUPPORTED_APIS.includes(propertyName)) continue; const filename = root.filename(); const { start } = memberExpr.range(); - const message = UNSUPPORTED_API_WARNINGS[propertyName]; + const message = UNSUPPORTED_API_WARNINGS[propertyName as keyof typeof UNSUPPORTED_API_WARNINGS]; console.warn(`${filename}:${start.line}:${start.column}: ${message}`); } } /** - * Processes destructured imports and replaces each call with styleText. - * e.g. const { red } = require('ansi-colors'); red('text') -> styleText('red', 'text') + * Transforms calls from destructured bindings — red('text') becomes styleText('red', 'text'). */ function processDestructuredImports( rootNode: SgNode, @@ -267,8 +258,7 @@ function processDestructuredImports( } /** - * Processes default/namespace imports and replaces chained member calls with styleText. - * e.g. ac.bold.red('text') -> styleText(['bold', 'red'], 'text') + * Transforms chained member calls — ac.bold.red('text') becomes styleText(['bold', 'red'], 'text'). */ function processDefaultImports( rootNode: SgNode, diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-api/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-api/expected.js index 366c814b..996ceee2 100644 --- a/recipes/ansi-colors-to-styletext/tests/unsupported-api/expected.js +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-api/expected.js @@ -1,4 +1,2 @@ -const { styleText } = require('node:util'); -ac.enabled = false; - -console.log(styleText('red', 'text')); \ No newline at end of file +const ac = require('ansi-colors'); +ac.enabled = false; \ No newline at end of file diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-api/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-api/input.js index fe8b9e9b..996ceee2 100644 --- a/recipes/ansi-colors-to-styletext/tests/unsupported-api/input.js +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-api/input.js @@ -1,4 +1,2 @@ const ac = require('ansi-colors'); -ac.enabled = false; - -console.log(ac.red('text')); \ No newline at end of file +ac.enabled = false; \ No newline at end of file From 2f1eb468c2fdb037ccc80c99d4adfb10890bbe40 Mon Sep 17 00:00:00 2001 From: Shamya Date: Mon, 6 Jul 2026 18:33:48 -0400 Subject: [PATCH 7/8] fix(ansi-colors-to-styletext): fix unsupported-api test and type-safe COMPATIBILITY_MAP access --- recipes/ansi-colors-to-styletext/src/workflow.ts | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/recipes/ansi-colors-to-styletext/src/workflow.ts b/recipes/ansi-colors-to-styletext/src/workflow.ts index 4d3d27e6..28c5a887 100644 --- a/recipes/ansi-colors-to-styletext/src/workflow.ts +++ b/recipes/ansi-colors-to-styletext/src/workflow.ts @@ -138,7 +138,8 @@ function getDestructuredNames( if (importedName) { const imported = importedName.text(); const local = alias ? alias.text() : imported; - names.push({ imported: COMPATIBILITY_MAP[imported] ?? imported, local }); + const mappedImported = COMPATIBILITY_MAP[imported as keyof typeof COMPATIBILITY_MAP]; + names.push({ imported: mappedImported ?? imported, local }); } } } @@ -158,13 +159,15 @@ function getDestructuredNames( for (const prop of properties) { if (prop.kind() === 'shorthand_property_identifier_pattern') { const name = prop.text(); - names.push({ imported: COMPATIBILITY_MAP[name] ?? name, local: name }); + const mappedImported = COMPATIBILITY_MAP[name as keyof typeof COMPATIBILITY_MAP]; + names.push({ imported: mappedImported ?? name, local: name }); } else if (prop.kind() === 'pair_pattern') { const key = prop.field('key'); const value = prop.field('value'); if (key && value) { const imported = key.text(); - names.push({ imported: COMPATIBILITY_MAP[imported] ?? imported, local: value.text() }); + const mappedImported = COMPATIBILITY_MAP[imported as keyof typeof COMPATIBILITY_MAP]; + names.push({ imported: mappedImported ?? imported, local: value.text() }); } } } @@ -190,7 +193,7 @@ function extractChainedStyles(node: SgNode, binding: string): string[] | nul if (UNSUPPORTED_APIS.includes(propertyName)) return null; - const normalizedName = COMPATIBILITY_MAP[propertyName] ?? propertyName; + const normalizedName = COMPATIBILITY_MAP[propertyName as keyof typeof COMPATIBILITY_MAP] ?? propertyName; if (objectNode.kind() === 'identifier') { if (objectNode.text() !== binding) return null; From 5105999d0f0e19c39ecb0d887fdac00ebb0c6b6b Mon Sep 17 00:00:00 2001 From: Augustin Mauroy <97875033+AugustinMauroy@users.noreply.github.com> Date: Mon, 10 Aug 2026 23:18:01 +0200 Subject: [PATCH 8/8] support unstyle --- package-lock.json | 2 +- recipes/ansi-colors-to-styletext/README.md | 22 +- recipes/ansi-colors-to-styletext/package.json | 2 +- .../src/remove-dependencies.ts | 2 +- .../ansi-colors-to-styletext/src/workflow.ts | 546 ++++++++++++++++-- .../tests/unstyle-bis/expected.js | 15 + .../tests/unstyle-bis/input.js | 15 + .../tests/unstyle-cjs/expected.js | 5 + .../tests/unstyle-cjs/input.js | 5 + .../tests/unstyle/expected.js | 5 + .../tests/unstyle/input.js | 5 + .../tests/unsupported-warnings/expected.js | 3 +- .../tests/unsupported-warnings/input.js | 3 +- 13 files changed, 567 insertions(+), 63 deletions(-) create mode 100644 recipes/ansi-colors-to-styletext/tests/unstyle-bis/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unstyle-bis/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unstyle-cjs/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unstyle-cjs/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unstyle/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unstyle/input.js diff --git a/package-lock.json b/package-lock.json index e2ce143b..865045ab 100644 --- a/package-lock.json +++ b/package-lock.json @@ -998,7 +998,7 @@ "@nodejs/codemod-utils": "*" }, "devDependencies": { - "@codemod.com/jssg-types": "^1.6.1" + "@codemod.com/jssg-types": "^1.6.3" } }, "recipes/axios-to-whatwg-fetch": { diff --git a/recipes/ansi-colors-to-styletext/README.md b/recipes/ansi-colors-to-styletext/README.md index 1c1d7d09..962930b5 100644 --- a/recipes/ansi-colors-to-styletext/README.md +++ b/recipes/ansi-colors-to-styletext/README.md @@ -2,20 +2,34 @@ This recipe migrates from the external `ansi-colors` package to Node.js's built-in `util.styleText` API. It transforms ansi-colors method calls to use the native Node.js styling functionality. +## Usage + +Run this codemod with: + +```sh +npx codemod @nodejs/ansi-colors-to-styletext +``` + ## Examples ```diff - import ac from 'ansi-colors'; -+ import { styleText } from 'node:util'; ++ import { styleText, stripVTControlCharacters } from 'node:util'; + - console.log(ac.red('Error message')); + console.log(styleText('red', 'Error message')); + - console.log(ac.green('Success message')); + console.log(styleText('green', 'Success message')); + +- console.log(ac.unstyle(ac.bold.blue('Info message'))); ++ console.log(stripVTControlCharacters(styleText(['bold', 'blue'], 'Info message'))); ``` ```diff - const ac = require('ansi-colors'); + const { styleText } = require('node:util'); + - console.log(ac.bold.red('Critical error')); + console.log(styleText(['bold', 'red'], 'Critical error')); ``` @@ -23,8 +37,10 @@ This recipe migrates from the external `ansi-colors` package to Node.js's built- ```diff - const { red, blue } = require('ansi-colors'); + const { styleText } = require('node:util'); + - console.log(red('Error')); + console.log(styleText('red', 'Error')); + - console.log(blue('Info')); + console.log(styleText('blue', 'Info')); ``` @@ -39,4 +55,6 @@ This recipe migrates from the external `ansi-colors` package to Node.js's built- - **Runtime toggles** like `ac.enabled = false` require manual intervention - **Custom themes and aliases** need to be rewritten as plain objects -- **Dynamic imports with `.then()`** are not transformed and require manual migration \ No newline at end of file +- **Dynamic imports with `.then()`** are not transformed and require manual migration + + diff --git a/recipes/ansi-colors-to-styletext/package.json b/recipes/ansi-colors-to-styletext/package.json index 53fbde32..43991d9d 100644 --- a/recipes/ansi-colors-to-styletext/package.json +++ b/recipes/ansi-colors-to-styletext/package.json @@ -18,7 +18,7 @@ "license": "MIT", "homepage": "https://github.com/nodejs/userland-migrations/blob/main/recipes/ansi-colors-to-styletext/README.md", "devDependencies": { - "@codemod.com/jssg-types": "^1.6.1" + "@codemod.com/jssg-types": "^1.6.3" }, "dependencies": { "@nodejs/codemod-utils": "*" diff --git a/recipes/ansi-colors-to-styletext/src/remove-dependencies.ts b/recipes/ansi-colors-to-styletext/src/remove-dependencies.ts index d0457493..73f42041 100644 --- a/recipes/ansi-colors-to-styletext/src/remove-dependencies.ts +++ b/recipes/ansi-colors-to-styletext/src/remove-dependencies.ts @@ -10,4 +10,4 @@ const transform: Transform = async (root) => { }); }; -export default transform; \ No newline at end of file +export default transform; diff --git a/recipes/ansi-colors-to-styletext/src/workflow.ts b/recipes/ansi-colors-to-styletext/src/workflow.ts index 28c5a887..df8bfae9 100644 --- a/recipes/ansi-colors-to-styletext/src/workflow.ts +++ b/recipes/ansi-colors-to-styletext/src/workflow.ts @@ -9,10 +9,13 @@ const COMPATIBILITY_MAP = { grey: 'blackBright', }; +const API_REPLACEMENTS = { + unstyle: 'stripVTControlCharacters', +}; + const UNSUPPORTED_API_WARNINGS = { enabled: `util.styleText has no equivalent runtime instance flag. Map this configuration to environment variables instead: set process.env.NO_COLOR='1' or NODE_DISABLE_COLORS='1' before application initialization.`, visible: `util.styleText lacks a visual toggling mechanism and will always return a string wrapper. Please guard the call site explicitly: const out = visible ? styleText('red', msg) : '';`, - unstyle: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, stripColor: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, hasAnsi: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, hasColor: `util.styleText does not expose an ANSI text stripper. Replace with a native regex str.replace(/\\x1b\\[[0-9;]*m/g, '') or install a zero-dependency package like strip-ansi.`, @@ -24,9 +27,15 @@ const UNSUPPORTED_API_WARNINGS = { const UNSUPPORTED_APIS = Object.keys(UNSUPPORTED_API_WARNINGS); +type RequiredApi = 'styleText' | 'stripVTControlCharacters'; + +/** + * Main codemod entry point. + */ export default function transform(root: SgRoot): string | null { const rootNode = root.root(); const edits: Edit[] = []; + const requiredApis = new Set(); const statements = getModuleDependencies(root, ANSI_COLORS_BINDING); if (!statements.length) return null; @@ -36,17 +45,33 @@ export default function transform(root: SgRoot): string | null { const destructuredNames = getDestructuredNames(statement); if (destructuredNames.length > 0) { - processDestructuredImports(rootNode, destructuredNames, edits); + processDestructuredImports( + rootNode, + destructuredNames, + edits, + requiredApis, + ); } else { const binding = getDefaultBinding(statement); + if (binding) { checkUnsupportedApis(rootNode, binding, root); - processDefaultImports(rootNode, binding, edits); + + processDefaultImports( + rootNode, + binding, + edits, + requiredApis, + ); } } if (edits.length > initialEditCount) { - const importReplacement = createImportReplacement(statement); + const importReplacement = createImportReplacement( + statement, + requiredApis, + ); + if (importReplacement) { edits.push(statement.replace(importReplacement)); } @@ -59,20 +84,29 @@ export default function transform(root: SgRoot): string | null { } /** - * Builds the replacement import line based on whether the original was ESM, CJS, or dynamic. + * Builds the replacement import line based on whether the original was + * ESM, CJS, or dynamic. */ -function createImportReplacement(statement: SgNode): string { +function createImportReplacement( + statement: SgNode, + requiredApis: Set, +): string { + const imports = [...requiredApis].join(', '); + + if (!imports) return ''; + const kind = statement.kind(); if (kind === 'import_statement') { - return `import { styleText } from 'node:util';`; + return `import { ${imports} } from 'node:util';`; } if (kind === 'variable_declarator') { if (statement.field('value')?.kind() === 'await_expression') { - return `{ styleText } = await import('node:util')`; + return `{ ${imports} } = await import('node:util')`; } - return `{ styleText } = require('node:util')`; + + return `{ ${imports} } = require('node:util')`; } return ''; @@ -99,6 +133,7 @@ function getDefaultBinding(statement: SgNode): string | null { }, }, }); + if (defaultImport) return defaultImport.text(); const namespaceImport = statement.find({ @@ -107,12 +142,16 @@ function getDefaultBinding(statement: SgNode): string | null { inside: { kind: 'namespace_import' }, }, }); + return namespaceImport?.text() ?? null; } if (kind === 'variable_declarator') { const nameField = statement.field('name'); - if (nameField?.kind() === 'identifier') return nameField.text(); + + if (nameField?.kind() === 'identifier') { + return nameField.text(); + } } return null; @@ -128,18 +167,29 @@ function getDestructuredNames( const kind = statement.kind(); if (kind === 'import_statement') { - const namedImports = statement.find({ rule: { kind: 'named_imports' } }); + const namedImports = statement.find({ + rule: { kind: 'named_imports' }, + }); if (namedImports) { - for (const specifier of namedImports.findAll({ rule: { kind: 'import_specifier' } })) { + for (const specifier of namedImports.findAll({ + rule: { kind: 'import_specifier' }, + })) { const importedName = specifier.field('name'); const alias = specifier.field('alias'); if (importedName) { const imported = importedName.text(); const local = alias ? alias.text() : imported; - const mappedImported = COMPATIBILITY_MAP[imported as keyof typeof COMPATIBILITY_MAP]; - names.push({ imported: mappedImported ?? imported, local }); + const mappedImported = + COMPATIBILITY_MAP[ + imported as keyof typeof COMPATIBILITY_MAP + ]; + + names.push({ + imported: mappedImported ?? imported, + local, + }); } } } @@ -157,17 +207,35 @@ function getDestructuredNames( }); for (const prop of properties) { - if (prop.kind() === 'shorthand_property_identifier_pattern') { + if ( + prop.kind() === + 'shorthand_property_identifier_pattern' + ) { const name = prop.text(); - const mappedImported = COMPATIBILITY_MAP[name as keyof typeof COMPATIBILITY_MAP]; - names.push({ imported: mappedImported ?? name, local: name }); + const mappedImported = + COMPATIBILITY_MAP[ + name as keyof typeof COMPATIBILITY_MAP + ]; + + names.push({ + imported: mappedImported ?? name, + local: name, + }); } else if (prop.kind() === 'pair_pattern') { const key = prop.field('key'); const value = prop.field('value'); + if (key && value) { const imported = key.text(); - const mappedImported = COMPATIBILITY_MAP[imported as keyof typeof COMPATIBILITY_MAP]; - names.push({ imported: mappedImported ?? imported, local: value.text() }); + const mappedImported = + COMPATIBILITY_MAP[ + imported as keyof typeof COMPATIBILITY_MAP + ]; + + names.push({ + imported: mappedImported ?? imported, + local: value.text(), + }); } } } @@ -181,28 +249,46 @@ function getDestructuredNames( * Walks a member expression chain and returns the ordered style names, * or null if the chain doesn't start from the expected binding. */ -function extractChainedStyles(node: SgNode, binding: string): string[] | null { +function extractChainedStyles( + node: SgNode, + binding: string, +): string[] | null { const objectNode = node.field('object'); const propertyNode = node.field('property'); - if (!objectNode || !propertyNode || propertyNode.kind() !== 'property_identifier') { + if ( + !objectNode || + !propertyNode || + propertyNode.kind() !== 'property_identifier' + ) { return null; } const propertyName = propertyNode.text(); - if (UNSUPPORTED_APIS.includes(propertyName)) return null; + if ( + UNSUPPORTED_APIS.includes(propertyName) || + propertyName in API_REPLACEMENTS + ) { + return null; + } - const normalizedName = COMPATIBILITY_MAP[propertyName as keyof typeof COMPATIBILITY_MAP] ?? propertyName; + const normalizedName = + COMPATIBILITY_MAP[ + propertyName as keyof typeof COMPATIBILITY_MAP + ] ?? propertyName; if (objectNode.kind() === 'identifier') { if (objectNode.text() !== binding) return null; + return [normalizedName]; } if (objectNode.kind() === 'member_expression') { const nested = extractChainedStyles(objectNode, binding); + if (!nested) return null; + return [...nested, normalizedName]; } @@ -212,7 +298,11 @@ function extractChainedStyles(node: SgNode, binding: string): string[] | nul /** * Emits targeted warnings for ansi-colors APIs with no util.styleText equivalent. */ -function checkUnsupportedApis(rootNode: SgNode, binding: string, root: SgRoot): void { +function checkUnsupportedApis( + rootNode: SgNode, + binding: string, + root: SgRoot, +): void { const memberExpressions = rootNode.findAll({ rule: { kind: 'member_expression' }, }); @@ -226,24 +316,46 @@ function checkUnsupportedApis(rootNode: SgNode, binding: string, root: SgRoo if (propertyNode.kind() !== 'property_identifier') continue; const propertyName = propertyNode.text(); + + // `unstyle` has a native Node.js equivalent, so it should not + // produce an unsupported API warning. + if (propertyName in API_REPLACEMENTS) continue; + if (!UNSUPPORTED_APIS.includes(propertyName)) continue; const filename = root.filename(); const { start } = memberExpr.range(); - const message = UNSUPPORTED_API_WARNINGS[propertyName as keyof typeof UNSUPPORTED_API_WARNINGS]; - console.warn(`${filename}:${start.line}:${start.column}: ${message}`); + const message = + UNSUPPORTED_API_WARNINGS[ + propertyName as keyof typeof UNSUPPORTED_API_WARNINGS + ]; + + console.warn( + `${filename}:${start.line}:${start.column}: ${message}`, + ); } } /** - * Transforms calls from destructured bindings — red('text') becomes styleText('red', 'text'). + * Transforms calls from destructured bindings. + * + * red('text') becomes: + * styleText('red', 'text') + * + * unstyle('text') becomes: + * stripVTControlCharacters('text') */ function processDestructuredImports( rootNode: SgNode, destructuredNames: Array<{ imported: string; local: string }>, edits: Edit[], + requiredApis: Set, ): void { for (const { local, imported } of destructuredNames) { + const replacement = API_REPLACEMENTS[ + imported as keyof typeof API_REPLACEMENTS + ]; + const calls = rootNode.findAll({ rule: { kind: 'call_expression', @@ -253,48 +365,374 @@ function processDestructuredImports( for (const call of calls) { const args = call.field('arguments'); - if (args) { - edits.push(call.replace(`styleText('${imported}', ${args.text().slice(1, -1)})`)); + + if (!args) continue; + + const textArg = args.text().slice(1, -1); + + if (replacement) { + requiredApis.add( + replacement as RequiredApi, + ); + + edits.push( + call.replace( + `${replacement}(${textArg})`, + ), + ); + } else { + requiredApis.add('styleText'); + + edits.push( + call.replace( + `styleText('${imported}', ${textArg})`, + ), + ); } } } } /** - * Transforms chained member calls — ac.bold.red('text') becomes styleText(['bold', 'red'], 'text'). + * Represents the transformation that can be applied to an ansi-colors + * call expression. + */ +type CallTransformation = { + replacement: string; + requiredApis: RequiredApi[]; +}; + +/** + * Returns the transformation for a call expression if it is an + * ansi-colors call belonging to the provided binding. + * + * Examples: + * + * colors.unstyle(value) + * -> + * stripVTControlCharacters(value) + * + * colors.bold.red(value) + * -> + * styleText(['bold', 'red'], value) + */ +function getCallTransformation( + call: SgNode, + binding: string, +): CallTransformation | null { + const functionNode = call.field('function'); + + if (functionNode?.kind() !== 'member_expression') { + return null; + } + + const propertyNode = functionNode.field('property'); + const objectNode = functionNode.field('object'); + + if ( + propertyNode?.kind() === 'property_identifier' && + propertyNode.text() in API_REPLACEMENTS && + objectNode?.kind() === 'identifier' && + objectNode.text() === binding + ) { + const replacement = + API_REPLACEMENTS[ + propertyNode.text() as keyof typeof API_REPLACEMENTS + ]; + + return { + replacement, + requiredApis: [replacement as RequiredApi], + }; + } + + const styles = extractChainedStyles( + functionNode, + binding, + ); + + if (!styles?.length) return null; + + const styleTextArgument = + styles.length === 1 + ? `'${styles[0]}'` + : `[${styles.map(style => `'${style}'`).join(', ')}]`; + + return { + replacement: `styleText(${styleTextArgument}`, + requiredApis: ['styleText'], + }; +} + +/** + * Compares two AST positions. + */ +function positionBeforeOrEqual( + a: { line: number; column: number }, + b: { line: number; column: number }, +): boolean { + return ( + a.line < b.line || + (a.line === b.line && a.column <= b.column) + ); +} + +/** + * Returns true when `outer` completely contains `inner`. + */ +function rangeContains( + outer: ReturnType['range']>, + inner: ReturnType['range']>, +): boolean { + return ( + positionBeforeOrEqual(outer.start, inner.start) && + positionBeforeOrEqual(inner.end, outer.end) + ); +} + +/** + * Returns only the outermost relevant calls from a collection of calls. + * + * This prevents overlapping edits such as: + * + * colors.unstyle(colors.bold('hello')) + * + * from producing separate edits for both calls. + * + * The outer call is edited once, and its nested calls are rendered + * recursively. + */ +function getOutermostCalls( + calls: SgNode[], +): SgNode[] { + return calls.filter(call => { + const callRange = call.range(); + + return !calls.some(other => { + if (other === call) return false; + + const otherRange = other.range(); + + if (!rangeContains(otherRange, callRange)) { + return false; + } + + // Equal ranges are not considered containment. + return ( + otherRange.start.line !== callRange.start.line || + otherRange.start.column !== callRange.start.column || + otherRange.end.line !== callRange.end.line || + otherRange.end.column !== callRange.end.column + ); + }); + }); +} + +/** + * Returns relevant nested calls that are not themselves contained by + * another relevant nested call. + * + * For: + * + * colors.unstyle( + * colors.bold( + * colors.blue('hello') + * ) + * ) + * + * the first level returned here is `colors.bold(...)`. + * `colors.blue(...)` is handled recursively by that call. + */ +function getOutermostNestedCalls( + args: SgNode, + binding: string, +): SgNode[] { + const candidates = args.findAll({ + rule: { kind: 'call_expression' }, + }).filter(call => { + return getCallTransformation(call, binding) !== null; + }); + + return getOutermostCalls(candidates); +} + +/** + * Recursively transforms ansi-colors calls nested inside an argument list. + * + * This is the important part for cases such as: + * + * colors.unstyle(colors.bold.blue('\u001b[34mhello\u001b[39m')) + * + * which becomes: + * + * stripVTControlCharacters(styleText(['bold', 'blue'], '\u001b[34mhello\u001b[39m')) + * + * The ANSI escape sequences are intentionally preserved here because + * `stripVTControlCharacters` is the outer operation that removes them. + */ +function transformNestedArguments( + args: SgNode, + binding: string, + requiredApis: Set, +): string { + let text = args.text().slice(1, -1); + + const nestedCalls = getOutermostNestedCalls( + args, + binding, + ); + + if (!nestedCalls.length) { + return text; + } + + /** + * `args.text()` and the nested call texts are both sourced from the + * same original AST, so processing the calls in source order lets us + * safely replace repeated nested expressions as well. + */ + nestedCalls.sort((a, b) => { + const aRange = a.range(); + const bRange = b.range(); + + if (aRange.start.line !== bRange.start.line) { + return aRange.start.line - bRange.start.line; + } + + return aRange.start.column - bRange.start.column; + }); + + let searchFrom = 0; + + for (const nestedCall of nestedCalls) { + const originalText = nestedCall.text(); + const transformedText = transformCallExpression( + nestedCall, + binding, + requiredApis, + ); + + if (transformedText === originalText) continue; + + const index = text.indexOf( + originalText, + searchFrom, + ); + + if (index === -1) continue; + + text = + text.slice(0, index) + + transformedText + + text.slice(index + originalText.length); + + searchFrom = + index + + transformedText.length; + } + + return text; +} + +/** + * Recursively transforms one ansi-colors call expression. + * + * This function does not create an AST edit itself. It renders the + * transformed call as a string so an outer call can incorporate the + * transformed result into its own replacement. + */ +function transformCallExpression( + call: SgNode, + binding: string, + requiredApis: Set, +): string { + const transformation = getCallTransformation( + call, + binding, + ); + + if (!transformation) { + return call.text(); + } + + for (const api of transformation.requiredApis) { + requiredApis.add(api); + } + + const args = call.field('arguments'); + + if (!args) { + return call.text(); + } + + const transformedArgs = transformNestedArguments( + args, + binding, + requiredApis, + ); + + if ( + transformation.replacement === + 'stripVTControlCharacters' + ) { + return `stripVTControlCharacters(${transformedArgs})`; + } + + return `${transformation.replacement}, ${transformedArgs})`; +} + +/** + * Transforms chained member calls, including nested ansi-colors calls. + * + * ac.bold.red('text') becomes: + * styleText(['bold', 'red'], 'text') + * + * ac.unstyle('text') becomes: + * stripVTControlCharacters('text') + * + * Nested calls are transformed recursively: + * + * ac.unstyle(ac.bold.red('text')) + * + * becomes: + * stripVTControlCharacters(styleText(['bold', 'red'], 'text')) */ function processDefaultImports( rootNode: SgNode, binding: string, edits: Edit[], + requiredApis: Set, ): void { - const calls = rootNode.findAll({ - rule: { - kind: 'call_expression', - has: { - field: 'function', - kind: 'member_expression', + const calls = rootNode + .findAll({ + rule: { + kind: 'call_expression', + has: { + field: 'function', + kind: 'member_expression', + }, }, - }, - }); - - for (const call of calls) { - const functionNode = call.field('function'); - if (functionNode?.kind() !== 'member_expression') continue; + }) + .filter(call => { + return getCallTransformation(call, binding) !== null; + }); - const styles = extractChainedStyles(functionNode, binding); - if (!styles?.length) continue; + /** + * Only edit the outermost relevant call. Nested calls are rendered + * recursively as part of the outer replacement, which avoids + * overlapping edits. + */ + const outermostCalls = getOutermostCalls(calls); - const args = call.field('arguments'); - if (!args) continue; + for (const call of outermostCalls) { + const replacement = transformCallExpression( + call, + binding, + requiredApis, + ); - const textArg = args.text().slice(1, -1); + if (replacement === call.text()) continue; - if (styles.length === 1) { - edits.push(call.replace(`styleText('${styles[0]}', ${textArg})`)); - } else { - const styleArray = styles.map(s => `'${s}'`).join(', '); - edits.push(call.replace(`styleText([${styleArray}], ${textArg})`)); - } + edits.push(call.replace(replacement)); } -} \ No newline at end of file +} diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle-bis/expected.js b/recipes/ansi-colors-to-styletext/tests/unstyle-bis/expected.js new file mode 100644 index 00000000..cd9af2df --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle-bis/expected.js @@ -0,0 +1,15 @@ +import { stripVTControlCharacters, styleText } from 'node:util'; + +const foo = stripVTControlCharacters(styleText(['bold', 'blue'], 'hello')); + +/** + * @param {string} text + * @returns {string} + */ +const restyle = (text) => { + const stripped = stripVTControlCharacters(text); + + return styleText(['bold', 'blue'], stripped); +} + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle-bis/input.js b/recipes/ansi-colors-to-styletext/tests/unstyle-bis/input.js new file mode 100644 index 00000000..99100c24 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle-bis/input.js @@ -0,0 +1,15 @@ +import colors from 'ansi-colors'; + +const foo = colors.unstyle(colors.bold.blue('hello')); + +/** + * @param {string} text + * @returns {string} + */ +const restyle = (text) => { + const stripped = colors.unstyle(text); + + return colors.bold.blue(stripped); +} + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/expected.js b/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/expected.js new file mode 100644 index 00000000..2560db7f --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/expected.js @@ -0,0 +1,5 @@ +const { stripVTControlCharacters } = require('node:util'); + +const foo = stripVTControlCharacters('\u001b[31mhello\u001b[39m'); + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/input.js b/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/input.js new file mode 100644 index 00000000..3d3b88a3 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle-cjs/input.js @@ -0,0 +1,5 @@ +const ac = require('ansi-colors'); + +const foo = ac.unstyle('\u001b[31mhello\u001b[39m'); + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle/expected.js b/recipes/ansi-colors-to-styletext/tests/unstyle/expected.js new file mode 100644 index 00000000..7868a3e4 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle/expected.js @@ -0,0 +1,5 @@ +import { stripVTControlCharacters } from 'node:util'; + +const foo = stripVTControlCharacters('\u001b[34mhello\u001b[39m'); + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unstyle/input.js b/recipes/ansi-colors-to-styletext/tests/unstyle/input.js new file mode 100644 index 00000000..e7bf2692 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unstyle/input.js @@ -0,0 +1,5 @@ +import colors from 'ansi-colors'; + +const foo = colors.unstyle('\u001b[34mhello\u001b[39m'); + +console.log(foo); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js index 215a57fb..eacf6422 100644 --- a/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js @@ -1,8 +1,7 @@ const { styleText } = require('node:util'); ac.enabled = false; ac.visible = false; -ac.unstyle('some text'); ac.alias('error', ac.bold.red); ac.theme({ error: ac.bold.red }); -console.log(styleText('red', 'text')); \ No newline at end of file +console.log(styleText('red', 'text')); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/input.js index 5723f132..8fa02523 100644 --- a/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/input.js +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/input.js @@ -1,8 +1,7 @@ const ac = require('ansi-colors'); ac.enabled = false; ac.visible = false; -ac.unstyle('some text'); ac.alias('error', ac.bold.red); ac.theme({ error: ac.bold.red }); -console.log(ac.red('text')); \ No newline at end of file +console.log(ac.red('text'));