From 7792bd090f1d5eee3b1bc5ff40149809af021530 Mon Sep 17 00:00:00 2001 From: with-heart Date: Fri, 14 Apr 2023 12:02:13 -0400 Subject: [PATCH 1/3] initialize @xstate/codemods package --- packages/codemods/babel.config.js | 6 ++++++ packages/codemods/package.json | 16 ++++++++++++++++ packages/codemods/src/index.ts | 0 packages/codemods/tsconfig.json | 10 ++++++++++ yarn.lock | 10 ++++++++++ 5 files changed, 42 insertions(+) create mode 100644 packages/codemods/babel.config.js create mode 100644 packages/codemods/package.json create mode 100644 packages/codemods/src/index.ts create mode 100644 packages/codemods/tsconfig.json diff --git a/packages/codemods/babel.config.js b/packages/codemods/babel.config.js new file mode 100644 index 00000000..d006e430 --- /dev/null +++ b/packages/codemods/babel.config.js @@ -0,0 +1,6 @@ +module.exports = { + presets: [ + ['@babel/preset-env', { targets: { node: '16' } }], + '@babel/preset-typescript', + ], +}; diff --git a/packages/codemods/package.json b/packages/codemods/package.json new file mode 100644 index 00000000..f94984dc --- /dev/null +++ b/packages/codemods/package.json @@ -0,0 +1,16 @@ +{ + "name": "@xstate/codemods", + "version": "0.0.0", + "license": "MIT", + "main": "dist/xstate-codemods.cjs.js", + "scripts": { + "test": "jest" + }, + "dependencies": { + "typescript": "^4.9.4" + }, + "devDependencies": { + "@tsconfig/node16": "^1.0.3", + "@tsconfig/strictest": "^2.0.0" + } +} diff --git a/packages/codemods/src/index.ts b/packages/codemods/src/index.ts new file mode 100644 index 00000000..e69de29b diff --git a/packages/codemods/tsconfig.json b/packages/codemods/tsconfig.json new file mode 100644 index 00000000..5435dd1b --- /dev/null +++ b/packages/codemods/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": [ + "@tsconfig/node16/tsconfig.json", + "@tsconfig/strictest/tsconfig.json" + ], + "compilerOptions": { + "module": "node16" + }, + "include": ["src"] +} diff --git a/yarn.lock b/yarn.lock index d04cdfdb..7ca067aa 100644 --- a/yarn.lock +++ b/yarn.lock @@ -1965,6 +1965,16 @@ resolved "https://registry.yarnpkg.com/@tootallnate/once/-/once-1.1.2.tgz#ccb91445360179a04e7fe6aff78c00ffc1eeaf82" integrity sha512-RbzJvlNzmRq5c3O09UipeuXno4tA1FE6ikOjxZK0tuxVv3412l64l5t1W5pj4+rJq9vpkm/kwiR07aZXnsKPxw== +"@tsconfig/node16@^1.0.3": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" + integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== + +"@tsconfig/strictest@^2.0.0": + version "2.0.0" + resolved "https://registry.yarnpkg.com/@tsconfig/strictest/-/strictest-2.0.0.tgz#9528a6298aa28ecc93b0c4c4e9ea6aac813779b5" + integrity sha512-E0dpiZNdwO20c8d3seh7OmjAvDpwoRkTcU6M8cvggzB45Bd45tyTU2XJeA5Wfq+8NzVGhunvqOJ30AjSkywMXA== + "@types/babel__core@^7.0.0", "@types/babel__core@^7.1.14", "@types/babel__core@^7.1.7": version "7.1.18" resolved "https://registry.yarnpkg.com/@types/babel__core/-/babel__core-7.1.18.tgz#1a29abcc411a9c05e2094c98f9a1b7da6cdf49f8" From b12a307e6e446363757e488faafe5f3c07a60c79 Mon Sep 17 00:00:00 2001 From: with-heart Date: Sat, 15 Apr 2023 16:33:27 -0400 Subject: [PATCH 2/3] initialize v4-to-v5 codemod --- packages/codemods/src/index.ts | 1 + packages/codemods/src/v4-to-v5/index.ts | 8 ++++++++ 2 files changed, 9 insertions(+) create mode 100644 packages/codemods/src/v4-to-v5/index.ts diff --git a/packages/codemods/src/index.ts b/packages/codemods/src/index.ts index e69de29b..c2cfa7d8 100644 --- a/packages/codemods/src/index.ts +++ b/packages/codemods/src/index.ts @@ -0,0 +1 @@ +export { v4ToV5 } from './v4-to-v5'; diff --git a/packages/codemods/src/v4-to-v5/index.ts b/packages/codemods/src/v4-to-v5/index.ts new file mode 100644 index 00000000..943f1d39 --- /dev/null +++ b/packages/codemods/src/v4-to-v5/index.ts @@ -0,0 +1,8 @@ +import ts from 'typescript'; + +export const v4ToV5: ts.TransformerFactory = + (context) => (sourceFile) => { + const { factory } = context; + + return sourceFile; + }; From 5fa384ee0df30b58b5d39bc359c86bb158cda5df Mon Sep 17 00:00:00 2001 From: with-heart Date: Thu, 27 Apr 2023 13:00:39 -0400 Subject: [PATCH 3/3] add machine-to-create-machine codemod --- packages/codemods/README.md | 31 +++ packages/codemods/package.json | 3 +- packages/codemods/src/v4-to-v5/index.ts | 9 +- .../codemods/src/v4-to-v5/predicates.test.ts | 226 ++++++++++++++++++ packages/codemods/src/v4-to-v5/predicates.ts | 85 +++++++ .../codemods/src/v4-to-v5/test-utils.test.ts | 13 + packages/codemods/src/v4-to-v5/test-utils.ts | 8 + .../machine-to-create-machine.test.ts | 83 +++++++ .../transformers/machine-to-create-machine.ts | 48 ++++ packages/codemods/src/v4-to-v5/utils.test.ts | 33 +++ packages/codemods/src/v4-to-v5/utils.ts | 40 ++++ 11 files changed, 571 insertions(+), 8 deletions(-) create mode 100644 packages/codemods/README.md create mode 100644 packages/codemods/src/v4-to-v5/predicates.test.ts create mode 100644 packages/codemods/src/v4-to-v5/predicates.ts create mode 100644 packages/codemods/src/v4-to-v5/test-utils.test.ts create mode 100644 packages/codemods/src/v4-to-v5/test-utils.ts create mode 100644 packages/codemods/src/v4-to-v5/transformers/machine-to-create-machine.test.ts create mode 100644 packages/codemods/src/v4-to-v5/transformers/machine-to-create-machine.ts create mode 100644 packages/codemods/src/v4-to-v5/utils.test.ts create mode 100644 packages/codemods/src/v4-to-v5/utils.ts diff --git a/packages/codemods/README.md b/packages/codemods/README.md new file mode 100644 index 00000000..d3d0f905 --- /dev/null +++ b/packages/codemods/README.md @@ -0,0 +1,31 @@ +# `@xstate/codemods` + +A library of automatic codebase refactors for [XState](https://github.com/statelyai/xstate). + +## `v4-to-v5` + +This codemod migrates a v4 codebase to v5. + +### `machine-to-create-machine` + +```diff +-import { Machine } from 'xstate'; ++import { createMachine } from 'xstate'; + +-const machine = Machine({}); ++const machine = createMachine({}); +``` + +```diff +-import { Machine as SomethingElse } from 'xstate'; ++import { createMachine as SomethingElse } from 'xstate'; + + const machine = SomethingElse({}) +``` + +```diff + import xstate from 'xstate'; + +-const machine = xstate.Machine({}); ++const machine = xstate.createMachine({}); +``` diff --git a/packages/codemods/package.json b/packages/codemods/package.json index f94984dc..8688f7dd 100644 --- a/packages/codemods/package.json +++ b/packages/codemods/package.json @@ -11,6 +11,7 @@ }, "devDependencies": { "@tsconfig/node16": "^1.0.3", - "@tsconfig/strictest": "^2.0.0" + "@tsconfig/strictest": "^2.0.0", + "outdent": "^0.8.0" } } diff --git a/packages/codemods/src/v4-to-v5/index.ts b/packages/codemods/src/v4-to-v5/index.ts index 943f1d39..138f25f3 100644 --- a/packages/codemods/src/v4-to-v5/index.ts +++ b/packages/codemods/src/v4-to-v5/index.ts @@ -1,8 +1,3 @@ -import ts from 'typescript'; +import { machineToCreateMachine } from './transformers/machine-to-create-machine'; -export const v4ToV5: ts.TransformerFactory = - (context) => (sourceFile) => { - const { factory } = context; - - return sourceFile; - }; +export const v4ToV5 = [machineToCreateMachine]; diff --git a/packages/codemods/src/v4-to-v5/predicates.test.ts b/packages/codemods/src/v4-to-v5/predicates.test.ts new file mode 100644 index 00000000..aaf7d5cb --- /dev/null +++ b/packages/codemods/src/v4-to-v5/predicates.test.ts @@ -0,0 +1,226 @@ +import outdent from 'outdent'; +import ts from 'typescript'; +import { + isDescendantOfXStateImport, + isMachineCallExpression, + isMachineNamedImportSpecifier, + isMachinePropertyAccessExpression, + isXStateImportClause, + isXStateImportDeclaration, +} from './predicates'; +import { parse } from './test-utils'; +import { findDescendant } from './utils'; + +describe('isXStateImportDeclaration', () => { + test.each<[code: string, expected: boolean]>([ + [`import xstate from 'xstate'`, true], + [`import { Machine } from 'xstate'`, true], + [`import { Machine as M } from 'xstate'`, true], + [`import xstate from 'not-xstate'`, false], + [`import { Machine } from 'not-xstate'`, false], + [`import { Machine as M } from 'not-xstate'`, false], + [`console.log('not an ImportDeclaration')`, false], + ])('%s (%s)', (code, expected) => { + const sourceFile = parse(code); + const node = findDescendant(sourceFile, isXStateImportDeclaration); + + if (expected) { + expect(node).not.toBeUndefined(); + expect(node!.kind).toBe(ts.SyntaxKind.ImportDeclaration); + } else { + expect(node).toBeUndefined(); + } + }); +}); + +describe('isDescendantOfXStateImport', () => { + test.each<[code: string, expected: boolean]>([ + [`import xstate from 'xstate'`, true], + [`import { Machine } from 'xstate'`, true], + [`import { Machine as M } from 'xstate'`, true], + [`import xstate from 'not-xstate'`, false], + [`import { Machine } from 'not-xstate'`, false], + [`import { Machine as M } from 'not-xstate'`, false], + ])(`%s (%s)`, (code, expected) => { + const sourceFile = parse(code); + const node = findDescendant(sourceFile, isDescendantOfXStateImport); + + if (expected) { + expect(node).not.toBeUndefined(); + } else { + expect(node).toBeUndefined(); + } + }); +}); + +describe('isXStateImportClause', () => { + test.each<[code: string, expected: boolean]>([ + [`import xstate from 'xstate'`, true], + [`import { Machine } from 'xstate'`, false], + [`import { Machine as M } from 'xstate'`, false], + [`import xstate from 'not-xstate'`, false], + [`import { Machine } from 'not-xstate'`, false], + [`import { Machine as M } from 'not-xstate'`, false], + ])('%s (%s)', (code, expected) => { + const sourceFile = parse(code); + const node = findDescendant(sourceFile, isXStateImportClause); + + if (expected) { + expect(node).not.toBeUndefined(); + expect(node!.kind).toBe(ts.SyntaxKind.ImportClause); + } else { + expect(node).toBeUndefined(); + } + }); +}); + +describe('isMachineNamedImportSpecifier', () => { + test.each<[code: string, expected: boolean]>([ + [`import { Machine } from 'xstate'`, true], + [`import { Machine as M } from 'xstate'`, true], + [`import { NotMachine } from 'xstate'`, false], + [`import { NotMachine as Machine } from 'xstate'`, false], + [`console.log('not an ImportSpecifier')`, false], + ])('%s (%s)', (code, expected) => { + const sourceFile = parse(code); + const node = findDescendant(sourceFile, isMachineNamedImportSpecifier); + + if (expected) { + expect(node).not.toBeUndefined(); + expect(node!.kind).toBe(ts.SyntaxKind.ImportSpecifier); + } else { + expect(node).toBeUndefined(); + } + }); +}); + +describe('isMachineCallExpression', () => { + test.each<[code: string, expected: boolean]>([ + [ + outdent` + import { Machine } from 'xstate' + const machine = Machine({}) + `, + true, + ], + [ + outdent` + import { Machine as M } from 'xstate' + const machine = M({}) + `, + false, + ], + [ + outdent` + import xstate from 'xstate' + const machine = xstate.Machine({}) + `, + false, + ], + [ + outdent` + import { Machine } from 'not-xstate' + const machine = Machine({}) + `, + false, + ], + [ + outdent` + function Machine() {} + const machine = Machine({}) + `, + false, + ], + [ + outdent` + console.log('non-xstate CallExpression') + `, + false, + ], + [ + outdent` + const str = "not a CallExpression" + `, + false, + ], + ])('%s (%s)', (code, expected) => { + const sourceFile = parse(code); + const node = findDescendant(sourceFile, isMachineCallExpression); + + if (expected) { + expect(node).not.toBeUndefined(); + expect(node!.kind).toBe(ts.SyntaxKind.CallExpression); + } else { + expect(node).toBeUndefined(); + } + }); +}); + +describe('isMachinePropertyAccessExpression', () => { + test.each<[input: string, expected: boolean]>([ + [ + outdent` + import xstate from 'xstate' + const machine = xstate.Machine({}) + `, + true, + ], + [ + outdent` + import xstate from 'xstate' + const Machine = xstate.Machine + `, + true, + ], + [ + outdent` + import { Machine } from 'xstate' + const machine = Machine({}) + `, + false, + ], + [ + outdent` + import { Machine as M } from 'xstate' + const machine = M({}) + `, + false, + ], + [ + outdent` + import { Machine } from 'not-xstate' + const machine = Machine({}) + `, + false, + ], + [ + outdent` + function Machine() {} + const machine = Machine({}) + `, + false, + ], + [ + outdent` + console.log('non-xstate PropertyAccessExpression') + `, + false, + ], + [ + outdent` + const str = "not a CallExpression" + `, + false, + ], + ])('%s (%s)', (code, expected) => { + const sourceFile = parse(code); + const node = findDescendant(sourceFile, isMachinePropertyAccessExpression); + + if (expected) { + expect(node).not.toBeUndefined(); + expect(node!.kind).toBe(ts.SyntaxKind.PropertyAccessExpression); + } else { + expect(node).toBeUndefined(); + } + }); +}); diff --git a/packages/codemods/src/v4-to-v5/predicates.ts b/packages/codemods/src/v4-to-v5/predicates.ts new file mode 100644 index 00000000..0e9aca2b --- /dev/null +++ b/packages/codemods/src/v4-to-v5/predicates.ts @@ -0,0 +1,85 @@ +import ts from 'typescript'; +import { findDescendant } from './utils'; + +export function isXStateImportDeclaration( + node: ts.Node, +): node is ts.ImportDeclaration { + return ( + ts.isImportDeclaration(node) && + ts.isStringLiteral(node.moduleSpecifier) && + node.moduleSpecifier.text === 'xstate' + ); +} + +export function isDescendantOfXStateImport(node: ts.Node): boolean { + return Boolean(ts.findAncestor(node, isXStateImportDeclaration)); +} + +export function isXStateImportClause( + node: ts.Node, +): node is ts.ImportClause & { name: ts.Identifier } { + return Boolean( + isDescendantOfXStateImport(node) && + ts.isImportClause(node) && + node.name && + ts.isIdentifier(node.name), + ); +} + +export function isMachineNamedImportSpecifier( + node: ts.Node, +): node is ts.ImportSpecifier { + return ( + isDescendantOfXStateImport(node) && + ts.isImportSpecifier(node) && + (node.propertyName + ? node.propertyName.text === 'Machine' + : node.name.text === 'Machine') + ); +} + +export function isMachineCallExpression( + node: ts.Node, +): node is ts.CallExpression & { expression: ts.Identifier } { + if (!ts.isCallExpression(node) || !ts.isIdentifier(node.expression)) { + return false; + } + + const sourceFile = node.getSourceFile(); + const namedImportSpecifier = findDescendant( + sourceFile, + isMachineNamedImportSpecifier, + ); + + if (!namedImportSpecifier) { + return false; + } + + return ( + node.expression.text === + (namedImportSpecifier.propertyName ?? namedImportSpecifier.name).text + ); +} + +export function isMachinePropertyAccessExpression( + node: ts.Node, +): node is ts.PropertyAccessExpression { + if ( + !ts.isPropertyAccessExpression(node) || + !ts.isIdentifier(node.expression) + ) { + return false; + } + + const sourceFile = node.getSourceFile(); + const xstateImportClause = findDescendant(sourceFile, isXStateImportClause); + + if (!xstateImportClause) { + return false; + } + + return ( + node.expression.text === xstateImportClause.name.text && + node.name.text === 'Machine' + ); +} diff --git a/packages/codemods/src/v4-to-v5/test-utils.test.ts b/packages/codemods/src/v4-to-v5/test-utils.test.ts new file mode 100644 index 00000000..0effdca8 --- /dev/null +++ b/packages/codemods/src/v4-to-v5/test-utils.test.ts @@ -0,0 +1,13 @@ +import ts from 'typescript'; +import { isXStateImportDeclaration } from './predicates'; +import { parse } from './test-utils'; +import { findDescendant } from './utils'; + +test('parse', () => { + const sourceFile = parse(`import { Machine } from 'xstate'`); + + expect(ts.isSourceFile(sourceFile)).toBe(true); + expect( + findDescendant(sourceFile, isXStateImportDeclaration), + ).not.toBeUndefined(); +}); diff --git a/packages/codemods/src/v4-to-v5/test-utils.ts b/packages/codemods/src/v4-to-v5/test-utils.ts new file mode 100644 index 00000000..a184bbc7 --- /dev/null +++ b/packages/codemods/src/v4-to-v5/test-utils.ts @@ -0,0 +1,8 @@ +import ts from 'typescript'; + +/** + * Parse a string of code into a TypeScript `SourceFile` node. + */ +export function parse(code: string, fileName: string = 'code.ts') { + return ts.createSourceFile(fileName, code, ts.ScriptTarget.Latest, true); +} diff --git a/packages/codemods/src/v4-to-v5/transformers/machine-to-create-machine.test.ts b/packages/codemods/src/v4-to-v5/transformers/machine-to-create-machine.test.ts new file mode 100644 index 00000000..6cb59a37 --- /dev/null +++ b/packages/codemods/src/v4-to-v5/transformers/machine-to-create-machine.test.ts @@ -0,0 +1,83 @@ +import outdent from 'outdent'; +import ts from 'typescript'; +import { parse } from '../test-utils'; +import { machineToCreateMachine } from './machine-to-create-machine'; + +const printer = ts.createPrinter({ + newLine: ts.NewLineKind.LineFeed, + removeComments: false, + omitTrailingSemicolon: true, +}); + +describe('changed', () => { + test.each<[input: string, expected: string]>([ + [ + `import { Machine } from 'xstate'`, + `import { createMachine } from 'xstate';`, + ], + [ + `import { Machine as M } from 'xstate'`, + `import { createMachine as M } from 'xstate';`, + ], + [ + outdent` + import { Machine } from 'xstate' + const machine = Machine({}) + `, + outdent` + import { createMachine } from 'xstate'; + const machine = createMachine({}); + `, + ], + [ + outdent` + import { Machine as M } from 'xstate' + const machine = M({}) + `, + outdent` + import { createMachine as M } from 'xstate'; + const machine = M({}); + `, + ], + [ + outdent` + import xstate from 'xstate' + const machine = xstate.Machine({}) + `, + outdent` + import xstate from 'xstate'; + const machine = xstate.createMachine({}); + `, + ], + [ + outdent` + import xstate from 'xstate' + const Machine = xstate.Machine + `, + outdent` + import xstate from 'xstate'; + const Machine = xstate.createMachine; + `, + ], + ])('%s -> %s', (input, expected) => { + const { + transformed: [output], + } = ts.transform(parse(input), [machineToCreateMachine]); + + expect(printer.printFile(output!)).toBe(`${expected}\n`); + }); +}); + +describe('unchanged', () => { + test.each([ + `import { NotMachine } from 'xstate'`, + `import xstate from 'xstate'`, + `import { Machine } from 'not-xstate'`, + ])('%s', (input) => { + const { + transformed: [output], + } = ts.transform(parse(input), [machineToCreateMachine]); + + expect(printer.printFile(output!)).toBe(`${input};\n`); + }); +}); diff --git a/packages/codemods/src/v4-to-v5/transformers/machine-to-create-machine.ts b/packages/codemods/src/v4-to-v5/transformers/machine-to-create-machine.ts new file mode 100644 index 00000000..74d1df58 --- /dev/null +++ b/packages/codemods/src/v4-to-v5/transformers/machine-to-create-machine.ts @@ -0,0 +1,48 @@ +import ts from 'typescript'; +import { + isMachineCallExpression, + isMachineNamedImportSpecifier, + isMachinePropertyAccessExpression, +} from '../predicates'; + +export const machineToCreateMachine: ts.TransformerFactory = + (context) => (sourceFile) => { + const { factory } = context; + return ts.visitNode(sourceFile, visit); + + function visit(node: ts.Node): ts.Node { + if (isMachineNamedImportSpecifier(node)) { + // if we have `propertyName`, it's using `as` syntax so we need to + // rename `propertyName`. otherwise we just rename `name`. + const [propertyName, name] = node.propertyName + ? [factory.createIdentifier('createMachine'), node.name] + : [node.propertyName, factory.createIdentifier('createMachine')]; + + return factory.updateImportSpecifier( + node, + node.isTypeOnly, + propertyName, + name, + ); + } + + if (isMachineCallExpression(node)) { + return factory.updateCallExpression( + node, + factory.createIdentifier('createMachine'), + node.typeArguments, + node.arguments, + ); + } + + if (isMachinePropertyAccessExpression(node)) { + return factory.updatePropertyAccessExpression( + node, + node.expression, + factory.createIdentifier('createMachine'), + ); + } + + return ts.visitEachChild(node, visit, context); + } + }; diff --git a/packages/codemods/src/v4-to-v5/utils.test.ts b/packages/codemods/src/v4-to-v5/utils.test.ts new file mode 100644 index 00000000..17d0e413 --- /dev/null +++ b/packages/codemods/src/v4-to-v5/utils.test.ts @@ -0,0 +1,33 @@ +import ts, { factory } from 'typescript'; +import { findDescendant } from './utils'; + +describe('findDescendant', () => { + test('finds a descendant node that matches the predicate', () => { + const stringLiteral = factory.createStringLiteral('Hello, world!'); + const node = factory.createVariableStatement( + [], + factory.createVariableDeclarationList([ + factory.createVariableDeclaration( + 'variable', + undefined, + undefined, + stringLiteral, + ), + ]), + ); + + expect(findDescendant(node, ts.isStringLiteral)).toBe(stringLiteral); + }); + + test('root node is returned if it matches the predicate', () => { + const node = factory.createNumericLiteral(69); + + expect(findDescendant(node, ts.isNumericLiteral)).toBe(node); + }); + + test('returns undefined if no descendant matches predicate', () => { + const node = factory.createNumericLiteral(69); + + expect(findDescendant(node, ts.isStringLiteral)).toBeUndefined(); + }); +}); diff --git a/packages/codemods/src/v4-to-v5/utils.ts b/packages/codemods/src/v4-to-v5/utils.ts new file mode 100644 index 00000000..d42a2c5e --- /dev/null +++ b/packages/codemods/src/v4-to-v5/utils.ts @@ -0,0 +1,40 @@ +import ts from 'typescript'; + +/** + * Finds a descendant node that matches `predicate`. + * + * If `predicate` returns a type predicate, the return type will be narrowed to + * that type. + * + * @example + * import ts from 'typescript'; + * + * const code = "const text = 'Hello, world!'"; + * const sourceFile = ts.createSourceFile( + * 'file.ts', + * code, + * ts.ScriptTarget.Latest, + * true, + * ); + * const stringLiteral = findDescendant(sourceFile, ts.isStringLiteral); + * + * console.log(stringLiteral?.text); // "Hello, world!" + */ +export function findDescendant( + node: ts.Node, + predicate: (node: ts.Node) => node is Node, +): Node | undefined; +export function findDescendant( + node: ts.Node, + predicate: (node: ts.Node) => boolean, +): ts.Node | undefined; +export function findDescendant( + node: ts.Node, + predicate: (node: ts.Node) => boolean, +): ts.Node | undefined { + if (predicate(node)) { + return node; + } + + return ts.forEachChild(node, (child) => findDescendant(child, predicate)); +}