From 85d654317b96e018aa4353f1ceebcc25d6d41119 Mon Sep 17 00:00:00 2001 From: Shamya Date: Tue, 2 Jun 2026 14:14:22 -0400 Subject: [PATCH 01/12] 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 0cafe12704bc148d4a61ef227c6a70c13d3e0813 Mon Sep 17 00:00:00 2001 From: Shamya Date: Wed, 3 Jun 2026 16:16:42 -0400 Subject: [PATCH 02/12] 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 7b0e23569ecd9e15759b50e1a76afcc31d4a1e45 Mon Sep 17 00:00:00 2001 From: Shamya Date: Wed, 3 Jun 2026 16:30:17 -0400 Subject: [PATCH 03/12] 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 de82792aae0204168d0df0cfa6672519041c19af Mon Sep 17 00:00:00 2001 From: Shamya Date: Thu, 4 Jun 2026 18:52:31 -0400 Subject: [PATCH 04/12] 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 ------------------ 14 files changed, 178 insertions(+), 630 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 34bf48db..1401c52d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -429,6 +429,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 @@ -987,6 +991,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", "dependencies": { 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 From f3288757598d745da7079e3628297c3d0f071c12 Mon Sep 17 00:00:00 2001 From: Shamya Date: Sat, 6 Jun 2026 01:20:47 -0400 Subject: [PATCH 05/12] 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 8602c3f529e36ae4ef4283d8b3cf112b42bbf982 Mon Sep 17 00:00:00 2001 From: Shamya Date: Mon, 6 Jul 2026 14:34:41 -0400 Subject: [PATCH 06/12] 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 25a62c71230e1192a417fcdf4ac0c74a34f02ee7 Mon Sep 17 00:00:00 2001 From: Shamya Date: Mon, 6 Jul 2026 18:33:48 -0400 Subject: [PATCH 07/12] 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 cc254e9de220d3d5ba8afdb7f7b3f15259b026b0 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 08/12] 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 1401c52d..0d921646 100644 --- a/package-lock.json +++ b/package-lock.json @@ -999,7 +999,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')); From ffdc459b174f5971774e31dd62f1791f6e3e2d3e Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 26 Sep 2026 17:33:31 -0500 Subject: [PATCH 09/12] fix(ansi-colors-to-styletext): preserve nested calls with AST edits --- .../ansi-colors-to-styletext/src/workflow.ts | 87 +++---------------- .../tests/literal-and-comment/expected.js | 2 + .../tests/literal-and-comment/input.js | 2 + .../tests/nested-named/expected.js | 2 + .../tests/nested-named/input.js | 2 + 5 files changed, 21 insertions(+), 74 deletions(-) create mode 100644 recipes/ansi-colors-to-styletext/tests/literal-and-comment/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/literal-and-comment/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/nested-named/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/nested-named/input.js diff --git a/recipes/ansi-colors-to-styletext/src/workflow.ts b/recipes/ansi-colors-to-styletext/src/workflow.ts index df8bfae9..bf4ed8cf 100644 --- a/recipes/ansi-colors-to-styletext/src/workflow.ts +++ b/recipes/ansi-colors-to-styletext/src/workflow.ts @@ -364,30 +364,19 @@ function processDestructuredImports( }); for (const call of calls) { - const args = call.field('arguments'); + const functionNode = call.field('function'); + const openingParen = call.field('arguments')?.child(0); - if (!args) continue; - - const textArg = args.text().slice(1, -1); + if (!functionNode || openingParen?.text() !== '(') continue; if (replacement) { - requiredApis.add( - replacement as RequiredApi, - ); - - edits.push( - call.replace( - `${replacement}(${textArg})`, - ), - ); + requiredApis.add(replacement as RequiredApi); + edits.push(functionNode.replace(replacement)); } else { requiredApis.add('styleText'); - - edits.push( - call.replace( - `styleText('${imported}', ${textArg})`, - ), - ); + // Leave argument expressions intact so nested calls have disjoint edits. + edits.push(functionNode.replace('styleText')); + edits.push(openingParen.replace(`('${imported}', `)); } } } @@ -575,63 +564,13 @@ function transformNestedArguments( binding: string, requiredApis: Set, ): string { - let text = args.text().slice(1, -1); - - const nestedCalls = getOutermostNestedCalls( - args, - binding, + const nestedCalls = getOutermostNestedCalls(args, binding); + const edits = nestedCalls.map(call => + call.replace(transformCallExpression(call, binding, requiredApis)), ); - 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; + // Apply AST ranges instead of searching for matching text in strings or comments. + return args.commitEdits(edits).slice(1, -1); } /** diff --git a/recipes/ansi-colors-to-styletext/tests/literal-and-comment/expected.js b/recipes/ansi-colors-to-styletext/tests/literal-and-comment/expected.js new file mode 100644 index 00000000..2fc48052 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/literal-and-comment/expected.js @@ -0,0 +1,2 @@ +import { stripVTControlCharacters, styleText } from 'node:util'; +stripVTControlCharacters("ac.red('x')" + /* ac.red('x') */ styleText('red', 'x')); diff --git a/recipes/ansi-colors-to-styletext/tests/literal-and-comment/input.js b/recipes/ansi-colors-to-styletext/tests/literal-and-comment/input.js new file mode 100644 index 00000000..f55536fb --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/literal-and-comment/input.js @@ -0,0 +1,2 @@ +import ac from 'ansi-colors'; +ac.unstyle("ac.red('x')" + /* ac.red('x') */ ac.red('x')); diff --git a/recipes/ansi-colors-to-styletext/tests/nested-named/expected.js b/recipes/ansi-colors-to-styletext/tests/nested-named/expected.js new file mode 100644 index 00000000..8ebd094f --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/nested-named/expected.js @@ -0,0 +1,2 @@ +import { styleText, stripVTControlCharacters } from 'node:util'; +stripVTControlCharacters(styleText('red', 'x')); diff --git a/recipes/ansi-colors-to-styletext/tests/nested-named/input.js b/recipes/ansi-colors-to-styletext/tests/nested-named/input.js new file mode 100644 index 00000000..64bdab36 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/nested-named/input.js @@ -0,0 +1,2 @@ +import { red, unstyle } from 'ansi-colors'; +unstyle(red('x')); From 2d8bd49d7f9b189d9b2689068a570ba6ce60e02c Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 26 Sep 2026 17:47:07 -0500 Subject: [PATCH 10/12] fix(ansi-colors-to-styletext): preserve unsupported named APIs --- .../ansi-colors-to-styletext/src/workflow.ts | 49 +++++++++++++++++++ .../tests/supported-named-aliases/expected.js | 2 + .../tests/supported-named-aliases/input.js | 2 + .../tests/unsupported-named-cjs/expected.js | 2 + .../tests/unsupported-named-cjs/input.js | 2 + .../unsupported-named-dynamic/expected.js | 3 ++ .../tests/unsupported-named-dynamic/input.js | 3 ++ .../tests/unsupported-named-esm/expected.js | 3 ++ .../tests/unsupported-named-esm/input.js | 3 ++ 9 files changed, 69 insertions(+) create mode 100644 recipes/ansi-colors-to-styletext/tests/supported-named-aliases/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/supported-named-aliases/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-named-cjs/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-named-cjs/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-named-dynamic/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-named-dynamic/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-named-esm/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-named-esm/input.js diff --git a/recipes/ansi-colors-to-styletext/src/workflow.ts b/recipes/ansi-colors-to-styletext/src/workflow.ts index bf4ed8cf..c443e31c 100644 --- a/recipes/ansi-colors-to-styletext/src/workflow.ts +++ b/recipes/ansi-colors-to-styletext/src/workflow.ts @@ -9,6 +9,50 @@ const COMPATIBILITY_MAP = { grey: 'blackBright', }; +// Shared ansi-colors and util.styleText formats; gray/grey are normalized above. +const SUPPORTED_STYLES = new Set([ + 'reset', + 'bold', + 'dim', + 'italic', + 'underline', + 'inverse', + 'hidden', + 'strikethrough', + 'black', + 'red', + 'green', + 'yellow', + 'blue', + 'magenta', + 'cyan', + 'white', + 'blackBright', + 'redBright', + 'greenBright', + 'yellowBright', + 'blueBright', + 'magentaBright', + 'cyanBright', + 'whiteBright', + 'bgBlack', + 'bgRed', + 'bgGreen', + 'bgYellow', + 'bgBlue', + 'bgMagenta', + 'bgCyan', + 'bgWhite', + 'bgBlackBright', + 'bgRedBright', + 'bgGreenBright', + 'bgYellowBright', + 'bgBlueBright', + 'bgMagentaBright', + 'bgCyanBright', + 'bgWhiteBright', +]); + const API_REPLACEMENTS = { unstyle: 'stripVTControlCharacters', }; @@ -351,6 +395,11 @@ function processDestructuredImports( edits: Edit[], requiredApis: Set, ): void { + // Replacing this import would also remove unsupported bindings. Leave it intact. + if (destructuredNames.some(({ imported }) => + !SUPPORTED_STYLES.has(imported) && !Object.hasOwn(API_REPLACEMENTS, imported) + )) return; + for (const { local, imported } of destructuredNames) { const replacement = API_REPLACEMENTS[ imported as keyof typeof API_REPLACEMENTS diff --git a/recipes/ansi-colors-to-styletext/tests/supported-named-aliases/expected.js b/recipes/ansi-colors-to-styletext/tests/supported-named-aliases/expected.js new file mode 100644 index 00000000..2fcad48f --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/supported-named-aliases/expected.js @@ -0,0 +1,2 @@ +const { styleText, stripVTControlCharacters } = require('node:util'); +stripVTControlCharacters(styleText('bgRedBright', styleText('blackBright', 'text'))); diff --git a/recipes/ansi-colors-to-styletext/tests/supported-named-aliases/input.js b/recipes/ansi-colors-to-styletext/tests/supported-named-aliases/input.js new file mode 100644 index 00000000..cde9a3b2 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/supported-named-aliases/input.js @@ -0,0 +1,2 @@ +const { gray: muted, bgRedBright: highlight, unstyle: strip } = require('ansi-colors'); +strip(highlight(muted('text'))); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-named-cjs/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-named-cjs/expected.js new file mode 100644 index 00000000..c0ee59b7 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-named-cjs/expected.js @@ -0,0 +1,2 @@ +const { hasColor } = require('ansi-colors'); +hasColor('text'); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-named-cjs/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-named-cjs/input.js new file mode 100644 index 00000000..c0ee59b7 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-named-cjs/input.js @@ -0,0 +1,2 @@ +const { hasColor } = require('ansi-colors'); +hasColor('text'); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-named-dynamic/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-named-dynamic/expected.js new file mode 100644 index 00000000..9c9cd8b2 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-named-dynamic/expected.js @@ -0,0 +1,3 @@ +const { red, noop: plain } = await import('ansi-colors'); +red('text'); +plain('text'); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-named-dynamic/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-named-dynamic/input.js new file mode 100644 index 00000000..9c9cd8b2 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-named-dynamic/input.js @@ -0,0 +1,3 @@ +const { red, noop: plain } = await import('ansi-colors'); +red('text'); +plain('text'); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-named-esm/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-named-esm/expected.js new file mode 100644 index 00000000..5cd2f8fc --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-named-esm/expected.js @@ -0,0 +1,3 @@ +import { red, hasColor as containsColor } from 'ansi-colors'; +red('text'); +containsColor('text'); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-named-esm/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-named-esm/input.js new file mode 100644 index 00000000..5cd2f8fc --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-named-esm/input.js @@ -0,0 +1,3 @@ +import { red, hasColor as containsColor } from 'ansi-colors'; +red('text'); +containsColor('text'); From 70e6ace1952ba89a05161e77de1e93bbebcfbd95 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 26 Sep 2026 17:49:32 -0500 Subject: [PATCH 11/12] fix(ansi-colors-to-styletext): preserve unsupported property APIs --- recipes/ansi-colors-to-styletext/src/workflow.ts | 15 +++++++++++++++ .../tests/supported-property-aliases/expected.js | 3 +++ .../tests/supported-property-aliases/input.js | 3 +++ .../tests/unsupported-property-cjs/expected.js | 2 ++ .../tests/unsupported-property-cjs/input.js | 2 ++ .../unsupported-property-dynamic/expected.js | 3 +++ .../tests/unsupported-property-dynamic/input.js | 3 +++ .../tests/unsupported-property-mixed/expected.js | 3 +++ .../tests/unsupported-property-mixed/input.js | 3 +++ .../tests/unsupported-warnings/expected.js | 4 ++-- 10 files changed, 39 insertions(+), 2 deletions(-) create mode 100644 recipes/ansi-colors-to-styletext/tests/supported-property-aliases/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/supported-property-aliases/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-property-cjs/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-property-cjs/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-property-dynamic/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-property-dynamic/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-property-mixed/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unsupported-property-mixed/input.js diff --git a/recipes/ansi-colors-to-styletext/src/workflow.ts b/recipes/ansi-colors-to-styletext/src/workflow.ts index c443e31c..c338da2e 100644 --- a/recipes/ansi-colors-to-styletext/src/workflow.ts +++ b/recipes/ansi-colors-to-styletext/src/workflow.ts @@ -322,6 +322,8 @@ function extractChainedStyles( propertyName as keyof typeof COMPATIBILITY_MAP ] ?? propertyName; + if (!SUPPORTED_STYLES.has(normalizedName)) return null; + if (objectNode.kind() === 'identifier') { if (objectNode.text() !== binding) return null; @@ -691,6 +693,19 @@ function processDefaultImports( edits: Edit[], requiredApis: Set, ): void { + // Keep the original binding when any property chain needs manual migration. + for (const member of rootNode.findAll({ rule: { kind: 'member_expression' } })) { + let base = member; + while (base.kind() === 'member_expression') base = base.field('object'); + if (base.kind() !== 'identifier' || base.text() !== binding) continue; + if (extractChainedStyles(member, binding)) continue; + if ( + member.field('object')?.kind() === 'identifier' && + Object.hasOwn(API_REPLACEMENTS, member.field('property')?.text() ?? '') + ) continue; + return; + } + const calls = rootNode .findAll({ rule: { diff --git a/recipes/ansi-colors-to-styletext/tests/supported-property-aliases/expected.js b/recipes/ansi-colors-to-styletext/tests/supported-property-aliases/expected.js new file mode 100644 index 00000000..e03d6a80 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/supported-property-aliases/expected.js @@ -0,0 +1,3 @@ +import { stripVTControlCharacters, styleText } from 'node:util'; +stripVTControlCharacters(styleText(['bold', 'blackBright'], 'text')); +styleText('bgRedBright', 'text'); diff --git a/recipes/ansi-colors-to-styletext/tests/supported-property-aliases/input.js b/recipes/ansi-colors-to-styletext/tests/supported-property-aliases/input.js new file mode 100644 index 00000000..29c7cad7 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/supported-property-aliases/input.js @@ -0,0 +1,3 @@ +import ac from 'ansi-colors'; +ac.unstyle(ac.bold.gray('text')); +ac.bgRedBright('text'); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-property-cjs/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-property-cjs/expected.js new file mode 100644 index 00000000..24d855a1 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-property-cjs/expected.js @@ -0,0 +1,2 @@ +const ac = require('ansi-colors'); +ac.noop('text'); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-property-cjs/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-property-cjs/input.js new file mode 100644 index 00000000..24d855a1 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-property-cjs/input.js @@ -0,0 +1,2 @@ +const ac = require('ansi-colors'); +ac.noop('text'); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-property-dynamic/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-property-dynamic/expected.js new file mode 100644 index 00000000..cbc40f75 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-property-dynamic/expected.js @@ -0,0 +1,3 @@ +const ac = await import('ansi-colors'); +ac.red('text'); +ac.clear('text'); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-property-dynamic/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-property-dynamic/input.js new file mode 100644 index 00000000..cbc40f75 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-property-dynamic/input.js @@ -0,0 +1,3 @@ +const ac = await import('ansi-colors'); +ac.red('text'); +ac.clear('text'); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-property-mixed/expected.js b/recipes/ansi-colors-to-styletext/tests/unsupported-property-mixed/expected.js new file mode 100644 index 00000000..744c4f21 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-property-mixed/expected.js @@ -0,0 +1,3 @@ +import ac from 'ansi-colors'; +ac.red('text'); +ac.unstyle(ac.bold.noop.red('text')); diff --git a/recipes/ansi-colors-to-styletext/tests/unsupported-property-mixed/input.js b/recipes/ansi-colors-to-styletext/tests/unsupported-property-mixed/input.js new file mode 100644 index 00000000..744c4f21 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-property-mixed/input.js @@ -0,0 +1,3 @@ +import ac from 'ansi-colors'; +ac.red('text'); +ac.unstyle(ac.bold.noop.red('text')); 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 eacf6422..8fa02523 100644 --- a/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js +++ b/recipes/ansi-colors-to-styletext/tests/unsupported-warnings/expected.js @@ -1,7 +1,7 @@ -const { styleText } = require('node:util'); +const ac = require('ansi-colors'); ac.enabled = false; ac.visible = false; ac.alias('error', ac.bold.red); ac.theme({ error: ac.bold.red }); -console.log(styleText('red', 'text')); +console.log(ac.red('text')); From 6043d6e4829d5a03326138856b6ebf3de3362112 Mon Sep 17 00:00:00 2001 From: Sebastian Beltran Date: Sat, 26 Sep 2026 18:05:16 -0500 Subject: [PATCH 12/12] fix(ansi-colors-to-styletext): respect shadowed bindings --- .../ansi-colors-to-styletext/src/workflow.ts | 38 ++++++++++++------- .../tests/shadowed-arrow/expected.js | 3 ++ .../tests/shadowed-arrow/input.js | 3 ++ .../tests/shadowed-default/expected.js | 3 ++ .../tests/shadowed-default/input.js | 3 ++ .../tests/shadowed-local/expected.js | 5 +++ .../tests/shadowed-local/input.js | 5 +++ .../tests/shadowed-named/expected.js | 3 ++ .../tests/shadowed-named/input.js | 3 ++ .../tests/shadowed-nested/expected.js | 3 ++ .../tests/shadowed-nested/input.js | 3 ++ .../unshadowed-default-expression/expected.js | 4 ++ .../unshadowed-default-expression/input.js | 4 ++ .../ansi-colors-to-styletext/workflow.yaml | 1 + 14 files changed, 68 insertions(+), 13 deletions(-) create mode 100644 recipes/ansi-colors-to-styletext/tests/shadowed-arrow/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/shadowed-arrow/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/shadowed-default/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/shadowed-default/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/shadowed-local/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/shadowed-local/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/shadowed-named/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/shadowed-named/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/shadowed-nested/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/shadowed-nested/input.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unshadowed-default-expression/expected.js create mode 100644 recipes/ansi-colors-to-styletext/tests/unshadowed-default-expression/input.js diff --git a/recipes/ansi-colors-to-styletext/src/workflow.ts b/recipes/ansi-colors-to-styletext/src/workflow.ts index c338da2e..6987f9d4 100644 --- a/recipes/ansi-colors-to-styletext/src/workflow.ts +++ b/recipes/ansi-colors-to-styletext/src/workflow.ts @@ -72,6 +72,7 @@ const UNSUPPORTED_API_WARNINGS = { const UNSUPPORTED_APIS = Object.keys(UNSUPPORTED_API_WARNINGS); type RequiredApi = 'styleText' | 'stripVTControlCharacters'; +type BindingMatcher = (node: SgNode) => boolean; /** * Main codemod entry point. @@ -87,18 +88,27 @@ export default function transform(root: SgRoot): string | null { for (const statement of statements) { const initialEditCount = edits.length; const destructuredNames = getDestructuredNames(statement); + const isFromStatement: BindingMatcher = (node) => { + const definition = node.definition({ resolveExternal: false }); + return definition !== null && + definition.root.filename() === root.filename() && + rangeContains(statement.range(), definition.node.range()); + }; if (destructuredNames.length > 0) { processDestructuredImports( rootNode, destructuredNames, + isFromStatement, edits, requiredApis, ); } else { - const binding = getDefaultBinding(statement); + const name = getDefaultBinding(statement); - if (binding) { + if (name) { + const binding: BindingMatcher = (node) => + node.text() === name && isFromStatement(node); checkUnsupportedApis(rootNode, binding, root); processDefaultImports( @@ -295,7 +305,7 @@ function getDestructuredNames( */ function extractChainedStyles( node: SgNode, - binding: string, + binding: BindingMatcher, ): string[] | null { const objectNode = node.field('object'); const propertyNode = node.field('property'); @@ -325,7 +335,7 @@ function extractChainedStyles( if (!SUPPORTED_STYLES.has(normalizedName)) return null; if (objectNode.kind() === 'identifier') { - if (objectNode.text() !== binding) return null; + if (!binding(objectNode)) return null; return [normalizedName]; } @@ -346,7 +356,7 @@ function extractChainedStyles( */ function checkUnsupportedApis( rootNode: SgNode, - binding: string, + binding: BindingMatcher, root: SgRoot, ): void { const memberExpressions = rootNode.findAll({ @@ -358,7 +368,7 @@ function checkUnsupportedApis( const propertyNode = memberExpr.field('property'); if (!objectNode || !propertyNode) continue; - if (objectNode.text() !== binding) continue; + if (!binding(objectNode)) continue; if (propertyNode.kind() !== 'property_identifier') continue; const propertyName = propertyNode.text(); @@ -394,6 +404,7 @@ function checkUnsupportedApis( function processDestructuredImports( rootNode: SgNode, destructuredNames: Array<{ imported: string; local: string }>, + isFromStatement: BindingMatcher, edits: Edit[], requiredApis: Set, ): void { @@ -419,6 +430,7 @@ function processDestructuredImports( const openingParen = call.field('arguments')?.child(0); if (!functionNode || openingParen?.text() !== '(') continue; + if (!isFromStatement(functionNode)) continue; if (replacement) { requiredApis.add(replacement as RequiredApi); @@ -458,7 +470,7 @@ type CallTransformation = { */ function getCallTransformation( call: SgNode, - binding: string, + binding: BindingMatcher, ): CallTransformation | null { const functionNode = call.field('function'); @@ -473,7 +485,7 @@ function getCallTransformation( propertyNode?.kind() === 'property_identifier' && propertyNode.text() in API_REPLACEMENTS && objectNode?.kind() === 'identifier' && - objectNode.text() === binding + binding(objectNode) ) { const replacement = API_REPLACEMENTS[ @@ -585,7 +597,7 @@ function getOutermostCalls( */ function getOutermostNestedCalls( args: SgNode, - binding: string, + binding: BindingMatcher, ): SgNode[] { const candidates = args.findAll({ rule: { kind: 'call_expression' }, @@ -612,7 +624,7 @@ function getOutermostNestedCalls( */ function transformNestedArguments( args: SgNode, - binding: string, + binding: BindingMatcher, requiredApis: Set, ): string { const nestedCalls = getOutermostNestedCalls(args, binding); @@ -633,7 +645,7 @@ function transformNestedArguments( */ function transformCallExpression( call: SgNode, - binding: string, + binding: BindingMatcher, requiredApis: Set, ): string { const transformation = getCallTransformation( @@ -689,7 +701,7 @@ function transformCallExpression( */ function processDefaultImports( rootNode: SgNode, - binding: string, + binding: BindingMatcher, edits: Edit[], requiredApis: Set, ): void { @@ -697,7 +709,7 @@ function processDefaultImports( for (const member of rootNode.findAll({ rule: { kind: 'member_expression' } })) { let base = member; while (base.kind() === 'member_expression') base = base.field('object'); - if (base.kind() !== 'identifier' || base.text() !== binding) continue; + if (base.kind() !== 'identifier' || !binding(base)) continue; if (extractChainedStyles(member, binding)) continue; if ( member.field('object')?.kind() === 'identifier' && diff --git a/recipes/ansi-colors-to-styletext/tests/shadowed-arrow/expected.js b/recipes/ansi-colors-to-styletext/tests/shadowed-arrow/expected.js new file mode 100644 index 00000000..3604ced7 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/shadowed-arrow/expected.js @@ -0,0 +1,3 @@ +import { styleText } from 'node:util'; +const render = ac => ac.red('local'); +styleText('red', 'imported'); diff --git a/recipes/ansi-colors-to-styletext/tests/shadowed-arrow/input.js b/recipes/ansi-colors-to-styletext/tests/shadowed-arrow/input.js new file mode 100644 index 00000000..d245de6d --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/shadowed-arrow/input.js @@ -0,0 +1,3 @@ +import ac from 'ansi-colors'; +const render = ac => ac.red('local'); +ac.red('imported'); diff --git a/recipes/ansi-colors-to-styletext/tests/shadowed-default/expected.js b/recipes/ansi-colors-to-styletext/tests/shadowed-default/expected.js new file mode 100644 index 00000000..265e8dc6 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/shadowed-default/expected.js @@ -0,0 +1,3 @@ +import { styleText } from 'node:util'; +function paint(ac) { return ac.red('local'); } +styleText('blue', 'imported'); diff --git a/recipes/ansi-colors-to-styletext/tests/shadowed-default/input.js b/recipes/ansi-colors-to-styletext/tests/shadowed-default/input.js new file mode 100644 index 00000000..a762c1ea --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/shadowed-default/input.js @@ -0,0 +1,3 @@ +import ac from 'ansi-colors'; +function paint(ac) { return ac.red('local'); } +ac.blue('imported'); diff --git a/recipes/ansi-colors-to-styletext/tests/shadowed-local/expected.js b/recipes/ansi-colors-to-styletext/tests/shadowed-local/expected.js new file mode 100644 index 00000000..1358e4ad --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/shadowed-local/expected.js @@ -0,0 +1,5 @@ +const { styleText } = require('node:util'); +function render() { ac.red('local'); var ac = custom; } +{ const { colors: ac } = other; ac.blue('local'); } +try {} catch (ac) { ac.green('local'); } +styleText('red', 'imported'); diff --git a/recipes/ansi-colors-to-styletext/tests/shadowed-local/input.js b/recipes/ansi-colors-to-styletext/tests/shadowed-local/input.js new file mode 100644 index 00000000..f8c97ea6 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/shadowed-local/input.js @@ -0,0 +1,5 @@ +const ac = require('ansi-colors'); +function render() { ac.red('local'); var ac = custom; } +{ const { colors: ac } = other; ac.blue('local'); } +try {} catch (ac) { ac.green('local'); } +ac.red('imported'); diff --git a/recipes/ansi-colors-to-styletext/tests/shadowed-named/expected.js b/recipes/ansi-colors-to-styletext/tests/shadowed-named/expected.js new file mode 100644 index 00000000..03d316f5 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/shadowed-named/expected.js @@ -0,0 +1,3 @@ +import { styleText } from 'node:util'; +const render = ({ paint }) => paint('local'); +styleText('red', 'imported'); diff --git a/recipes/ansi-colors-to-styletext/tests/shadowed-named/input.js b/recipes/ansi-colors-to-styletext/tests/shadowed-named/input.js new file mode 100644 index 00000000..99384942 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/shadowed-named/input.js @@ -0,0 +1,3 @@ +import { red as paint } from 'ansi-colors'; +const render = ({ paint }) => paint('local'); +paint('imported'); diff --git a/recipes/ansi-colors-to-styletext/tests/shadowed-nested/expected.js b/recipes/ansi-colors-to-styletext/tests/shadowed-nested/expected.js new file mode 100644 index 00000000..cdd856ea --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/shadowed-nested/expected.js @@ -0,0 +1,3 @@ +import { styleText } from 'node:util'; +styleText('red', ((ac) => ac.blue('local'))(custom)); +function f(ac) { return ac.noop('local'); } diff --git a/recipes/ansi-colors-to-styletext/tests/shadowed-nested/input.js b/recipes/ansi-colors-to-styletext/tests/shadowed-nested/input.js new file mode 100644 index 00000000..faf8425c --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/shadowed-nested/input.js @@ -0,0 +1,3 @@ +import ac from 'ansi-colors'; +ac.red(((ac) => ac.blue('local'))(custom)); +function f(ac) { return ac.noop('local'); } diff --git a/recipes/ansi-colors-to-styletext/tests/unshadowed-default-expression/expected.js b/recipes/ansi-colors-to-styletext/tests/unshadowed-default-expression/expected.js new file mode 100644 index 00000000..d1e4eaad --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unshadowed-default-expression/expected.js @@ -0,0 +1,4 @@ +import { styleText } from 'node:util'; +function render(color = styleText('red', 'default')) { return color; } +const { ac: other } = settings; +styleText('blue', 'imported'); diff --git a/recipes/ansi-colors-to-styletext/tests/unshadowed-default-expression/input.js b/recipes/ansi-colors-to-styletext/tests/unshadowed-default-expression/input.js new file mode 100644 index 00000000..ddfb4563 --- /dev/null +++ b/recipes/ansi-colors-to-styletext/tests/unshadowed-default-expression/input.js @@ -0,0 +1,4 @@ +import ac from 'ansi-colors'; +function render(color = ac.red('default')) { return color; } +const { ac: other } = settings; +ac.blue('imported'); diff --git a/recipes/ansi-colors-to-styletext/workflow.yaml b/recipes/ansi-colors-to-styletext/workflow.yaml index e68a1534..10f01be3 100644 --- a/recipes/ansi-colors-to-styletext/workflow.yaml +++ b/recipes/ansi-colors-to-styletext/workflow.yaml @@ -23,6 +23,7 @@ nodes: exclude: - "**/node_modules/**" language: typescript + semantic_analysis: file - id: remove-dependencies name: Remove ansi-colors dependency