├── .npmrc ├── .prettierignore ├── bin.js ├── src ├── setup │ ├── ts-config │ │ ├── index.ts │ │ └── ts-config.ts │ ├── suggest-types │ │ ├── index.ts │ │ └── suggest-types.ts │ └── install-typescript │ │ ├── index.ts │ │ └── install-typescript.ts ├── convert │ ├── flow │ │ ├── __mocks__ │ │ │ └── type-at-pos.ts │ │ ├── execute-type-at-pos.ts │ │ ├── annotate-params.ts │ │ ├── type-at-pos.test.ts │ │ ├── type-at-pos.ts │ │ └── annotate-params.test.ts │ ├── jsx-spread │ │ ├── index.ts │ │ ├── get-component-type.ts │ │ ├── components-with-spreads.ts │ │ └── jsx-spread.ts │ ├── migrate │ │ ├── metadata.ts │ │ ├── qualified-identifier.ts │ │ ├── type-parameter.ts │ │ ├── function-parameter.ts │ │ └── object-members.ts │ ├── no-flow-transformer-chain.ts │ ├── patterns.test.ts │ ├── transformer.d.ts │ ├── patterns.ts │ ├── utils │ │ ├── matchers.ts │ │ ├── type-mappings.ts │ │ ├── handle-async-function-return-type.ts │ │ ├── configurable-type-provider.ts │ │ ├── testing.ts │ │ └── common.ts │ ├── add-imports.ts │ ├── default-transformer-chain.ts │ ├── jsx.ts │ ├── add-watermark.ts │ ├── private-types.ts │ ├── remove-flow-comments.ts │ ├── add-watermark.test.ts │ ├── jsx.test.ts │ ├── private-types.test.ts │ ├── transform-runners.ts │ ├── type-annotations.ts │ ├── remove-flow-comments.test.ts │ ├── function-visitor.test.ts │ └── function-visitor.ts ├── fix │ ├── auto-import │ │ ├── __test__ │ │ │ ├── export.ts │ │ │ ├── needs-imports.ts │ │ │ ├── tsconfig.json │ │ │ └── auto-import.test.ts │ │ └── index.ts │ ├── fix-type-exports │ │ ├── __test__ │ │ │ ├── test-input.ts │ │ │ ├── __snapshots__ │ │ │ │ └── fix-type-exports.test.ts.snap │ │ │ ├── tsconfig.json │ │ │ └── fix-type-exports.test.ts │ │ └── index.ts │ ├── suppressions │ │ ├── shared.ts │ │ ├── __test__ │ │ │ ├── test-input.ts │ │ │ ├── tsconfig.json │ │ │ ├── __snapshots__ │ │ │ │ ├── remove-unused.test.ts.snap │ │ │ │ └── auto-suppress.test.ts.snap │ │ │ ├── auto-suppress.test.ts │ │ │ └── remove-unused.test.ts │ │ ├── diagnostic-to-description.ts │ │ ├── remove-unused.ts │ │ └── auto-suppress.ts │ ├── ts-node-traversal.ts │ ├── get-import-named.ts │ ├── insuppressible-errors.ts │ ├── generate-report │ │ ├── sorting.test.ts │ │ └── index.ts │ ├── state.ts │ ├── build-diagnostic-message-filter.ts │ └── component-prop-checks │ │ └── index.ts ├── test │ ├── test-files │ │ ├── .flowconfig │ │ ├── no-flow.js │ │ ├── obj-const.js │ │ ├── auto-supress-tests │ │ │ ├── tsconfig.json │ │ │ └── jsx-fragment.tsx │ │ └── flow_typescript_differences.js │ ├── typings │ │ └── index.d.ts │ └── regression │ │ ├── __snapshots__ │ │ └── regression.test.ts.snap │ │ └── regression.test.ts ├── runner │ ├── migration-reporter │ │ ├── formatters │ │ │ ├── types.ts │ │ │ ├── json-formatter.ts │ │ │ ├── csv-formatter.ts │ │ │ └── std-out-formatter.ts │ │ ├── index.ts │ │ └── migration-reporter.test.ts │ ├── logger.ts │ ├── run-transforms.ts │ ├── flow-header-removal.test.ts │ ├── state.ts │ ├── run-setup-command.ts │ ├── run-worker-async.ts │ ├── process-batch.ts │ └── find-flow-files.ts ├── index.ts └── cli │ └── arguments.ts ├── llama.png ├── .gitignore ├── jest.config.js ├── commit-in-chunks.sh ├── add-globals.sh ├── convert-declarations.sh ├── patches └── recast+0.20.4.patch ├── esBuild.js ├── LICENSE ├── tsconfig.json ├── .eslintrc ├── flow.d.ts └── package.json /.npmrc: -------------------------------------------------------------------------------- 1 | registry=https://registry.npmjs.org/ -------------------------------------------------------------------------------- /.prettierignore: -------------------------------------------------------------------------------- 1 | dist 2 | src/test/test-files/ 3 | -------------------------------------------------------------------------------- /bin.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | require('./dist/index.js'); 3 | -------------------------------------------------------------------------------- /src/setup/ts-config/index.ts: -------------------------------------------------------------------------------- 1 | export { defaultTsConfig } from "./ts-config"; 2 | -------------------------------------------------------------------------------- /src/convert/flow/__mocks__/type-at-pos.ts: -------------------------------------------------------------------------------- 1 | export const flowTypeAtPos = jest.fn(); 2 | -------------------------------------------------------------------------------- /src/convert/jsx-spread/index.ts: -------------------------------------------------------------------------------- 1 | export { transformJsxSpread } from "./jsx-spread"; 2 | -------------------------------------------------------------------------------- /src/setup/suggest-types/index.ts: -------------------------------------------------------------------------------- 1 | export { suggestTypes } from "./suggest-types"; 2 | -------------------------------------------------------------------------------- /llama.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/CodeSignal/flow-to-typescript-codemod/main/llama.png -------------------------------------------------------------------------------- /src/fix/auto-import/__test__/export.ts: -------------------------------------------------------------------------------- 1 | export type ExportedType = { 2 | type: "type"; 3 | }; 4 | -------------------------------------------------------------------------------- /src/fix/auto-import/__test__/needs-imports.ts: -------------------------------------------------------------------------------- 1 | export const a: ExportedType = { type: "type" }; 2 | -------------------------------------------------------------------------------- /src/setup/install-typescript/index.ts: -------------------------------------------------------------------------------- 1 | export { installTypescript } from "./install-typescript"; 2 | -------------------------------------------------------------------------------- /src/test/test-files/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | 11 | [strict] 12 | -------------------------------------------------------------------------------- /src/test/typings/index.d.ts: -------------------------------------------------------------------------------- 1 | export {}; 2 | declare global { 3 | namespace jest { 4 | interface Matchers { 5 | toHaveNoTypeScriptErrors(): R; 6 | } 7 | } 8 | } 9 | -------------------------------------------------------------------------------- /src/runner/migration-reporter/formatters/types.ts: -------------------------------------------------------------------------------- 1 | import { MigrationReport } from ".."; 2 | 3 | export type MigrationReportFormatter = ( 4 | report: MigrationReport 5 | ) => Promise; 6 | -------------------------------------------------------------------------------- /src/runner/migration-reporter/index.ts: -------------------------------------------------------------------------------- 1 | import MigrationReporter, { MigrationReport } from "./migration-reporter"; 2 | 3 | export default MigrationReporter; 4 | export type { MigrationReport }; 5 | -------------------------------------------------------------------------------- /src/test/test-files/no-flow.js: -------------------------------------------------------------------------------- 1 | // @noflow 2 | 3 | function helloWorld() { 4 | print('hello_world'); 5 | } 6 | helloWorld(); 7 | 8 | class Generic { 9 | foo: a; 10 | } 11 | -------------------------------------------------------------------------------- /src/convert/migrate/metadata.ts: -------------------------------------------------------------------------------- 1 | import { NodePath } from "@babel/traverse"; 2 | import t from "@babel/types"; 3 | 4 | export interface MetaData { 5 | returnType?: boolean; 6 | path?: NodePath; 7 | isInterfaceBody?: boolean; 8 | isTypeParameter?: boolean; 9 | } 10 | -------------------------------------------------------------------------------- /src/convert/no-flow-transformer-chain.ts: -------------------------------------------------------------------------------- 1 | import { jsxTransformRunner, hasJsxTransformRunner } from "./transform-runners"; 2 | import { Transformer } from "./transformer"; 3 | 4 | export const noFlowTransformerChain: readonly Transformer[] = [ 5 | hasJsxTransformRunner, 6 | jsxTransformRunner, 7 | ]; 8 | -------------------------------------------------------------------------------- /src/fix/fix-type-exports/__test__/test-input.ts: -------------------------------------------------------------------------------- 1 | import React, { Component, ElementType, RefObject } from "react"; 2 | 3 | // Comment #1 4 | 5 | export { Key, ReactNode as Node } from "react"; 6 | 7 | // Comment #2 8 | 9 | export { Component, ElementType, RefObject }; 10 | 11 | // Comment #3 12 | -------------------------------------------------------------------------------- /src/fix/suppressions/shared.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic } from "ts-morph"; 2 | 3 | export enum CommentType { 4 | Standard = "Standard", 5 | Jsx = "Jsx", 6 | } 7 | 8 | export interface CommentToMake { 9 | position: number; 10 | commentType: CommentType; 11 | diagnostics: Array; 12 | } 13 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | # Logs 2 | yarn-debug.log* 3 | yarn-error.log* 4 | 5 | # Dependency directories 6 | node_modules/ 7 | 8 | # Build output directories 9 | transpiled/ 10 | dist/ 11 | src/test/test-files/*.ts 12 | src/test/test-files/*.tsx 13 | 14 | .DS_Store 15 | .vscode/ 16 | *.tsbuildinfo 17 | .idea/ 18 | 19 | coverage/ 20 | 21 | CodeSignal 22 | -------------------------------------------------------------------------------- /jest.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | automock: false, 3 | transform: {'\\.ts$': ['ts-jest']}, 4 | modulePathIgnorePatterns: ['/dist/'], 5 | // https://github.com/adaltas/node-csv/issues/309 6 | moduleNameMapper: { 7 | '^csv-stringify/sync': 8 | '/node_modules/csv-stringify/dist/cjs/sync.cjs', 9 | }, 10 | }; 11 | -------------------------------------------------------------------------------- /src/convert/patterns.test.ts: -------------------------------------------------------------------------------- 1 | import { transform } from "./utils/testing"; 2 | 3 | describe("transform patterns", () => { 4 | it("converts function assigned parameters", async () => { 5 | const src = `function f(x?: T = y){};`; 6 | const expected = `function f(x: T = y){};`; 7 | expect(await transform(src)).toBe(expected); 8 | }); 9 | }); 10 | -------------------------------------------------------------------------------- /src/runner/logger.ts: -------------------------------------------------------------------------------- 1 | import { Signale } from "signale"; 2 | 3 | /** 4 | * Create a Signale logger, that is either normal or loading 5 | */ 6 | const buildLogger = (interactive = false) => { 7 | return new Signale({ 8 | stream: process.stdout, 9 | interactive, 10 | }); 11 | }; 12 | export const logger = buildLogger(); 13 | export const interactiveLogger = buildLogger(true); 14 | -------------------------------------------------------------------------------- /src/runner/migration-reporter/formatters/json-formatter.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { relative } from "path"; 3 | import { MigrationReport } from ".."; 4 | 5 | export function jsonFormatter(filePath: string) { 6 | return (report: MigrationReport) => { 7 | return fs.promises.writeFile( 8 | relative(process.cwd(), filePath), 9 | JSON.stringify(report) 10 | ); 11 | }; 12 | } 13 | -------------------------------------------------------------------------------- /src/convert/transformer.d.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import MigrationReporter from "../runner/migration-reporter"; 3 | import { State } from "../runner/state"; 4 | 5 | export type TransformerInput = { 6 | reporter: MigrationReporter; 7 | state: State; 8 | file: t.File; 9 | }; 10 | 11 | export type Transformer = ( 12 | transformerInput: TransformerInput 13 | ) => Promise | T; 14 | -------------------------------------------------------------------------------- /src/fix/ts-node-traversal.ts: -------------------------------------------------------------------------------- 1 | import { Node, ts } from "ts-morph"; 2 | 3 | export function getParentUntil( 4 | node: Node | undefined, 5 | isKind: (node: ts.Node) => node is S 6 | ): Node | undefined { 7 | if (!node) { 8 | return; 9 | } 10 | 11 | if (isKind(node.compilerNode)) { 12 | return node as Node; 13 | } 14 | 15 | return getParentUntil(node.getParent(), isKind); 16 | } 17 | -------------------------------------------------------------------------------- /commit-in-chunks.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd CodeSignal || exit 6 | 7 | git commit -m 'Chunk commit' || 8 | 9 | chunks=( $(git add -n --all) ) 10 | 11 | i=0 12 | for chunk in "${chunks[@]}" 13 | do 14 | ((i=i+1)) 15 | if [ $((i%2)) -eq 0 ] 16 | then 17 | git add "${chunk:1:${#chunk}-2}" 18 | fi 19 | if [ $((i%100)) -eq 0 ] 20 | then 21 | git commit -m 'Chunk commit' 22 | fi 23 | done 24 | 25 | git commit -m 'Chunk commit' 26 | -------------------------------------------------------------------------------- /add-globals.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | file_name="CodeSignal/declarations/globals.d.ts" 6 | 7 | cat << EOF > "$file_name" 8 | export {}; 9 | 10 | declare global { 11 | const Assets: any; 12 | const CoffeeScript: any; 13 | const Future: any; 14 | const Iron: any; 15 | const HTTP: any; 16 | const Sass: any; 17 | const zE: any; 18 | 19 | // from node_modules 20 | type Long = any; 21 | type OffscreenCanvas = any; 22 | } 23 | 24 | EOF 25 | -------------------------------------------------------------------------------- /src/test/test-files/obj-const.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | 3 | export const Sizes = { 4 | '8': 8, 5 | '12': 12, 6 | '14': 14, 7 | '16': 16, 8 | '20': 20, 9 | '32': 32, 10 | '40': 40, 11 | '64': 64, 12 | '128': 128, 13 | }; 14 | export const SizesTyped: {[string]: number} = { 15 | '8': 8, 16 | '12': 12, 17 | '14': 14, 18 | '16': 16, 19 | '20': 20, 20 | '32': 32, 21 | '40': 40, 22 | '64': 64, 23 | '128': 128, 24 | }; 25 | 26 | export const TestArray = [1, 2, 3]; 27 | -------------------------------------------------------------------------------- /src/fix/fix-type-exports/__test__/__snapshots__/fix-type-exports.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`fixTypeExports fixes mismatched type imports and exports 1`] = ` 4 | "import React, { Component, ElementType, RefObject } from \\"react\\"; 5 | 6 | // Comment #1 7 | 8 | export type { Key, ReactNode as Node } from \\"react\\"; 9 | 10 | // Comment #2 11 | 12 | export { Component }; 13 | 14 | // Comment #3 15 | export type { ElementType, RefObject }; 16 | " 17 | `; 18 | -------------------------------------------------------------------------------- /src/fix/get-import-named.ts: -------------------------------------------------------------------------------- 1 | import { SourceFile } from "ts-morph"; 2 | 3 | export function getImportNamed(sourceFile: SourceFile, name: string) { 4 | return sourceFile.getImportDeclaration((declaration) => { 5 | const defaultImport = declaration.getDefaultImport(); 6 | 7 | if (defaultImport?.compilerNode.escapedText === name) { 8 | return true; 9 | } 10 | return declaration.getNamedImports().some((theImport) => { 11 | return theImport.getName() === name; 12 | }); 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /convert-declarations.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -e 4 | 5 | cd CodeSignal || exit 6 | 7 | rm imports/declarations/utilityTypes.ts 8 | 9 | files=( $(ls imports/declarations) ) 10 | 11 | for file in "${files[@]}" 12 | do 13 | file_path="imports/declarations/$file" 14 | sed -i '' 's/export//g' "$file_path" 15 | content=$(cat "${file_path}") 16 | 17 | cat << EOF > "$file_path" 18 | export {}; 19 | 20 | declare global { 21 | ${content} 22 | } 23 | EOF 24 | 25 | git mv "$file_path" "${file_path::${#file_path}-2}d.ts" 26 | done 27 | -------------------------------------------------------------------------------- /src/convert/patterns.ts: -------------------------------------------------------------------------------- 1 | import traverse from "@babel/traverse"; 2 | import { TransformerInput } from "./transformer"; 3 | 4 | export function transformPatterns({ file }: TransformerInput) { 5 | traverse(file, { 6 | AssignmentPattern(path) { 7 | // `function f(x?: T = y)` → `function f(x: T = y)` 8 | if ( 9 | path.node.right && 10 | path.node.left.type === "Identifier" && 11 | path.node.left.optional 12 | ) { 13 | path.node.left.optional = false; 14 | } 15 | }, 16 | }); 17 | } 18 | -------------------------------------------------------------------------------- /src/fix/suppressions/__test__/test-input.ts: -------------------------------------------------------------------------------- 1 | // Testing removing unused 2 | 3 | const error1: string = 0; 4 | 5 | // Line above suppression 6 | // @ts-expect-error not actually error 7 | export const foo = () => { 8 | console.log( 9 | // Other comment 10 | "foo", 11 | // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type '{ customer: string; ephemeralKey: string; } | null'. 12 | "bar", 13 | "baz" 14 | ); 15 | 16 | const error2: string = 0; 17 | 18 | /* @ts-expect-error not actually error */ 19 | return "bar"; 20 | }; 21 | -------------------------------------------------------------------------------- /src/test/test-files/auto-supress-tests/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "incremental": true, 6 | "isolatedModules": true, 7 | "jsx": "react", 8 | "moduleResolution": "node", 9 | "noEmit": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": false, 13 | "noImplicitThis": true, 14 | "lib": ["dom","dom.iterable", "esnext"], 15 | "resolveJsonModule": true, 16 | "strict": true, 17 | }, 18 | "include": ["./jsx-fragment.tsx"] 19 | } 20 | -------------------------------------------------------------------------------- /src/fix/insuppressible-errors.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, ts } from "ts-morph"; 2 | 3 | /** 4 | * This is NOT an exhaustive list. But rather a list of errors that we found 5 | * that continued to break our TS build even though they were suppressed. 6 | * 7 | * Feel free to add additional error codes here as you find them. 8 | */ 9 | export const insuppressibleErrors = new Set([ 10 | 1383, 2304, 2306, 2503, 2578, 4025, 6059, 6307, 11 | ]); 12 | 13 | export function isDiagnosticSuppressible( 14 | diagnostic: Diagnostic 15 | ): boolean { 16 | return !insuppressibleErrors.has(diagnostic.getCode()); 17 | } 18 | -------------------------------------------------------------------------------- /src/fix/suppressions/__test__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "incremental": true, 6 | "isolatedModules": true, 7 | "jsx": "react", 8 | "moduleResolution": "node", 9 | "noEmit": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": false, 13 | "noImplicitThis": true, 14 | "lib": ["dom","dom.iterable", "esnext"], 15 | "resolveJsonModule": true, 16 | "strict": true, 17 | }, 18 | "include": ["*.ts"], 19 | "exclude": ["*.test.ts"], 20 | } 21 | -------------------------------------------------------------------------------- /src/fix/fix-type-exports/__test__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "incremental": true, 6 | "isolatedModules": true, 7 | "jsx": "react", 8 | "moduleResolution": "node", 9 | "noEmit": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": false, 13 | "noImplicitThis": true, 14 | "lib": ["dom","dom.iterable", "esnext"], 15 | "resolveJsonModule": true, 16 | "strict": true, 17 | }, 18 | "include": ["*.ts"], 19 | "exclude": ["*.test.ts"], 20 | } 21 | -------------------------------------------------------------------------------- /src/runner/run-transforms.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import { Transformer } from "../convert/transformer"; 3 | import MigrationReporter from "./migration-reporter"; 4 | import { State } from "./state"; 5 | 6 | /** 7 | * Run all transforms in order, given a chain of transforms 8 | */ 9 | export async function runTransforms( 10 | reporter: MigrationReporter, 11 | state: State, 12 | file: t.File, 13 | transforms: readonly Transformer[] 14 | ): Promise { 15 | for (const transform of transforms) { 16 | // eslint-disable-next-line no-await-in-loop 17 | await transform({ reporter, state, file }); 18 | } 19 | } 20 | -------------------------------------------------------------------------------- /src/convert/migrate/qualified-identifier.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import { inheritLocAndComments } from "../utils/common"; 3 | 4 | export function migrateQualifiedIdentifier( 5 | identifier: t.Identifier | t.QualifiedTypeIdentifier 6 | ): t.Identifier | t.TSQualifiedName { 7 | if (identifier.type === "Identifier") { 8 | return identifier; 9 | } else { 10 | const tsQualifiedName = t.tsQualifiedName( 11 | migrateQualifiedIdentifier(identifier.qualification), 12 | identifier.id 13 | ); 14 | inheritLocAndComments(identifier.qualification, tsQualifiedName); 15 | return tsQualifiedName; 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /src/fix/auto-import/__test__/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "incremental": true, 6 | "isolatedModules": true, 7 | "jsx": "react", 8 | "moduleResolution": "node", 9 | "noEmit": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": false, 13 | "noImplicitThis": true, 14 | "lib": ["dom","dom.iterable", "esnext"], 15 | "resolveJsonModule": true, 16 | "strict": true, 17 | }, 18 | "include": ["needs-imports.ts", "./export.ts"], 19 | "exclude": ["auto-import.test.ts"] 20 | } 21 | -------------------------------------------------------------------------------- /src/convert/utils/matchers.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | 3 | export function isIdentifierNamed(name: string) { 4 | return (node: t.TSEntityName) => t.isIdentifier(node) && node.name === name; 5 | } 6 | 7 | /** 8 | * Utility for quickly checking qualifed type like React.Node 9 | * @param leftName - Left side of the type, e.g. React 10 | * @param rightName - Right side of the type 11 | */ 12 | export function matchesFullyQualifiedName(leftName: string, rightName: string) { 13 | const leftMatcher = isIdentifierNamed(leftName); 14 | const rightMatcher = isIdentifierNamed(rightName); 15 | return (node: t.Identifier | t.TSQualifiedName) => 16 | t.isTSQualifiedName(node) && 17 | leftMatcher(node.left) && 18 | rightMatcher(node.right); 19 | } 20 | -------------------------------------------------------------------------------- /src/convert/add-imports.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import traverse from "@babel/traverse"; 3 | import { TransformerInput } from "./transformer"; 4 | 5 | /** 6 | * If any of the transforms used a utility type, we need to import them 7 | * @param state 8 | * @param file 9 | */ 10 | export function addImports({ state, file }: TransformerInput) { 11 | traverse(file, { 12 | Program: { 13 | exit(path) { 14 | if (state.usedUtils) { 15 | const importDeclaration = t.importDeclaration( 16 | [t.importSpecifier(t.identifier("Flow"), t.identifier("Flow"))], 17 | t.stringLiteral("flow-to-typescript-codemod") 18 | ); 19 | path.node.body = [importDeclaration, ...path.node.body]; 20 | } 21 | }, 22 | }, 23 | }); 24 | } 25 | -------------------------------------------------------------------------------- /src/setup/install-typescript/install-typescript.ts: -------------------------------------------------------------------------------- 1 | // Install the appropriate TypeScript in the current directory. 2 | import path from "path"; 3 | import { exec } from "child_process"; 4 | import { existsSync } from "fs"; 5 | import { logger } from "../../runner/logger"; 6 | 7 | /** 8 | * Install a fixed version of typescript 9 | */ 10 | export function installTypescript() { 11 | return new Promise((resolve, reject) => { 12 | if (!existsSync(path.join(process.cwd(), "package.json"))) { 13 | throw new Error("Must run this in a directory with a package.json"); 14 | } 15 | 16 | exec("yarn add --dev typescript@4.6.4", (err, stdout) => { 17 | if (err) { 18 | logger.error("Real Err:", err); 19 | return reject(err); 20 | } 21 | 22 | resolve(stdout); 23 | }); 24 | }); 25 | } 26 | -------------------------------------------------------------------------------- /src/convert/flow/execute-type-at-pos.ts: -------------------------------------------------------------------------------- 1 | import childProcess from "child_process"; 2 | import * as t from "@babel/types"; 3 | 4 | /** 5 | * Actually executes `flow type-at-pos`. This will be called behind a throttle. 6 | */ 7 | export async function executeFlowTypeAtPos( 8 | filePath: string, 9 | location: t.SourceLocation 10 | ): Promise { 11 | const { line, column } = location.start; 12 | const command = `$(yarn bin)/flow type-at-pos "${filePath}" ${line} ${ 13 | column + 1 14 | } --json --from "typescriptify" --quiet`; 15 | 16 | // Actually run Flow... 17 | const stdout = await new Promise((resolve, reject) => { 18 | childProcess.exec(command, (error, stdout) => { 19 | if (error) { 20 | reject(error); 21 | } else { 22 | resolve(stdout); 23 | } 24 | }); 25 | }); 26 | return stdout; 27 | } 28 | -------------------------------------------------------------------------------- /src/test/test-files/auto-supress-tests/jsx-fragment.tsx: -------------------------------------------------------------------------------- 1 | /* eslint-disable */ 2 | import * as React from 'react'; 3 | 4 | type AbstractComponent = React.ComponentType< 5 | React.PropsWithoutRef & React.RefAttributes 6 | >; 7 | 8 | function Test({test}: {test: number}) { 9 | return
{test}
; 10 | } 11 | 12 | function Image() { 13 | return ( 14 | <> 15 | 16 | 17 | 18 | ); 19 | } 20 | 21 | function add5(a: number) { 22 | return a + 1; 23 | } 24 | add5(5); 25 | 26 | class MyComponent extends React.Component { 27 | render() { 28 | return ( 29 | 30 | {this.props.test} 31 | 32 | ); 33 | } 34 | } 35 | 36 | 37 | const f = (() =>
38 | 39 | {'Profiling TTN'} 40 | 41 |
) as AbstractComponent>; 42 | -------------------------------------------------------------------------------- /src/convert/default-transformer-chain.ts: -------------------------------------------------------------------------------- 1 | import { 2 | declarationsTransformRunner, 3 | expressionTransformRunner, 4 | jsxTransformRunner, 5 | hasJsxTransformRunner, 6 | importTransformRunner, 7 | jsxSpreadTransformRunner, 8 | patternTransformRunner, 9 | privateTypeTransformRunner, 10 | typeAnnotationTransformRunner, 11 | removeFlowCommentTransformRunner, 12 | } from "./transform-runners"; 13 | import { Transformer } from "./transformer"; 14 | 15 | /** 16 | * Default chain of babel transforms to run. Order will be preserved. 17 | */ 18 | export const defaultTransformerChain: readonly Transformer[] = [ 19 | hasJsxTransformRunner, 20 | jsxTransformRunner, 21 | privateTypeTransformRunner, 22 | expressionTransformRunner, 23 | declarationsTransformRunner, 24 | typeAnnotationTransformRunner, 25 | patternTransformRunner, 26 | jsxSpreadTransformRunner, 27 | importTransformRunner, 28 | removeFlowCommentTransformRunner, 29 | ]; 30 | -------------------------------------------------------------------------------- /src/runner/flow-header-removal.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import { FlowCommentRegex } from "./process-batch"; 3 | 4 | const code = dedent` 5 | const t = 5; 6 | console.log('hi') 7 | `; 8 | 9 | const remove = (s: string) => { 10 | const joined = s + code; 11 | return joined.replace(FlowCommentRegex, ""); 12 | }; 13 | 14 | describe("Test flow comment removal", () => { 15 | it("removes basic", () => { 16 | expect(remove("// @flow\n")).toBe(code); 17 | }); 18 | 19 | it("does not remove missing @", () => { 20 | const src = "// flow\n"; 21 | expect(remove(src)).toBe(src + code); 22 | }); 23 | 24 | it("removes double", () => { 25 | expect(remove("// // @flow\n")).toBe(code); 26 | }); 27 | 28 | it("removes triple", () => { 29 | expect(remove("// // // @flow\n")).toBe(code); 30 | }); 31 | 32 | it("removes ignoring spaces", () => { 33 | expect(remove("/// // @flow\n")).toBe(code); 34 | }); 35 | }); 36 | -------------------------------------------------------------------------------- /patches/recast+0.20.4.patch: -------------------------------------------------------------------------------- 1 | diff --git a/node_modules/recast/lib/printer.js b/node_modules/recast/lib/printer.js 2 | index 8cbc392..470b3c4 100644 3 | --- a/node_modules/recast/lib/printer.js 4 | +++ b/node_modules/recast/lib/printer.js 5 | @@ -1730,8 +1730,12 @@ function genericPrintNoParens(path, options, print) { 6 | } 7 | case "TSTypeParameterDeclaration": 8 | case "TSTypeParameterInstantiation": 9 | + // If the first parameter has a comment, we want to insert a new line to avoid causing a syntax error: 10 | + const parameterNode = path.getValue() 11 | + const [firstParam] = parameterNode.params || []; 12 | return lines_1.concat([ 13 | "<", 14 | + firstParam && firstParam.comments && firstParam.comments.length ? lines_1.fromString("\n") : lines_1.fromString(""), 15 | lines_1.fromString(", ").join(path.map(print, "params")), 16 | ">", 17 | ]); 18 | -------------------------------------------------------------------------------- /esBuild.js: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env node 2 | /* eslint-disable @typescript-eslint/no-var-requires */ 3 | 4 | // yargs and esbuild have some incompatibility. This fixes it 5 | // See https://github.com/evanw/esbuild/issues/1492 6 | const importMeta = { 7 | name: 'Import Meta', 8 | setup({onLoad}) { 9 | const fs = require('fs'); 10 | const url = require('url'); 11 | onLoad({filter: /.*/}, (args) => { 12 | let code = fs.readFileSync(args.path, 'utf8'); 13 | code = code.replace( 14 | /\bimport\.meta\.url\b/g, 15 | JSON.stringify(url.pathToFileURL(args.path)), 16 | ); 17 | return {contents: code, loader: 'default'}; 18 | }); 19 | }, 20 | }; 21 | 22 | require('esbuild') 23 | .build({ 24 | entryPoints: ['src/index.ts'], 25 | bundle: true, 26 | external: ['babylon'], 27 | platform: 'node', 28 | format: 'cjs', 29 | outdir: 'dist', 30 | plugins: [importMeta], 31 | // Linked sourcemap 32 | sourcemap: true, 33 | }) 34 | .catch(() => process.exit(1)); 35 | -------------------------------------------------------------------------------- /src/fix/generate-report/sorting.test.ts: -------------------------------------------------------------------------------- 1 | import { compare, ReportRow } from "."; 2 | 3 | describe("Report Generator", () => { 4 | describe("sorts reports", () => { 5 | it("should sort reports by code and path", () => { 6 | const table: Array = [ 7 | ["4", "invalid", "source", "/src/a.js", "true"], 8 | ["3", "invalid", "source", "/src/a.js", "true"], 9 | ["3", "invalid", "source", "/src/d.js", "true"], 10 | ["3", "invalid", "source", "/src/c.js", "true"], 11 | ["2", "invalid", "source", "/src/b.js", "true"], 12 | ]; 13 | 14 | const report = table.sort(compare); 15 | 16 | expect(report).toStrictEqual([ 17 | ["2", "invalid", "source", "/src/b.js", "true"], 18 | ["3", "invalid", "source", "/src/a.js", "true"], 19 | ["3", "invalid", "source", "/src/c.js", "true"], 20 | ["3", "invalid", "source", "/src/d.js", "true"], 21 | ["4", "invalid", "source", "/src/a.js", "true"], 22 | ]); 23 | }); 24 | }); 25 | }); 26 | -------------------------------------------------------------------------------- /src/fix/state.ts: -------------------------------------------------------------------------------- 1 | import { Project } from "ts-morph"; 2 | import { FixCommandCliArgs } from "../cli/arguments"; 3 | import { logger } from "../runner/logger"; 4 | import MigrationReporter from "../runner/migration-reporter"; 5 | 6 | export interface FixCommandState { 7 | argv: FixCommandCliArgs; 8 | migrationReporter: MigrationReporter; 9 | project: Project; 10 | } 11 | 12 | export const getDiagnostics = (project: Project) => { 13 | logger.info("Getting type errors.."); 14 | const diagnostics = project.getPreEmitDiagnostics(); 15 | logger.info(`${diagnostics.length} type errors received`); 16 | return diagnostics; 17 | }; 18 | 19 | export const getFixState = (argv: FixCommandCliArgs): FixCommandState => { 20 | // Setup 21 | logger.info("Fixing types.."); 22 | 23 | const migrationReporter = new MigrationReporter(); 24 | 25 | logger.info("Starting TypeScript.."); 26 | const project = new Project({ 27 | tsConfigFilePath: argv.config, 28 | }); 29 | 30 | return { argv, migrationReporter, project }; 31 | }; 32 | -------------------------------------------------------------------------------- /src/fix/suppressions/diagnostic-to-description.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, DiagnosticMessageChain } from "ts-morph"; 2 | 3 | /** 4 | * Detect chains of errors, vs a single error 5 | */ 6 | function isDiagnosticMessageChain( 7 | message: string | DiagnosticMessageChain 8 | ): message is DiagnosticMessageChain { 9 | return typeof message !== "string"; 10 | } 11 | 12 | /** 13 | * Check if TS error is actually a chain of multiple errors, and return the 14 | * main error message. 15 | */ 16 | function getDiagnosticMessage(diagnostic: Diagnostic): string { 17 | const messageText = diagnostic.getMessageText(); 18 | if (isDiagnosticMessageChain(messageText)) { 19 | return messageText.getMessageText(); 20 | } 21 | 22 | return messageText; 23 | } 24 | 25 | /** 26 | * Format a TS error diagnostic as a formatted description 27 | */ 28 | export function diagnosticToDescription(diagnostic: Diagnostic): string { 29 | const messageText = getDiagnosticMessage(diagnostic); 30 | return `TS${diagnostic.getCode()} - ${messageText}`; 31 | } 32 | -------------------------------------------------------------------------------- /src/fix/auto-import/__test__/auto-import.test.ts: -------------------------------------------------------------------------------- 1 | import { autoImport } from ".."; 2 | import { 3 | expectMigrationReporterMethodCalled, 4 | getTestFixState, 5 | } from "../../../convert/utils/testing"; 6 | 7 | jest.mock("../../../runner/migration-reporter"); 8 | jest.mock("../../../runner/logger"); 9 | 10 | describe("autoImport", () => { 11 | beforeEach(() => { 12 | jest.resetAllMocks(); 13 | }); 14 | 15 | it("automatically imports types that are not currently imported", async () => { 16 | await autoImport( 17 | getTestFixState({ 18 | tsProps: false, 19 | autoSuppressErrors: false, 20 | generateReport: false, 21 | jiraSlug: "", 22 | useIgnore: false, 23 | removeUnused: false, 24 | config: "./src/fix/auto-import/__test__/tsconfig.json", 25 | format: "stdout", 26 | silent: false, 27 | output: "", 28 | autoImport: false, 29 | fixTypeExports: false, 30 | }), 31 | false 32 | ); 33 | 34 | expectMigrationReporterMethodCalled(`autoImport`); 35 | }); 36 | }); 37 | -------------------------------------------------------------------------------- /src/convert/jsx.ts: -------------------------------------------------------------------------------- 1 | import traverse from "@babel/traverse"; 2 | import { TransformerInput } from "./transformer"; 3 | import { getLoc } from "./utils/common"; 4 | 5 | /** 6 | * Scan JSX nodes, and update text that will not work in TS 7 | */ 8 | export function transformJSX({ file, reporter, state }: TransformerInput) { 9 | traverse(file, { 10 | // Flow/Babel are fine with having unescaped greater than ('>') symbols in JSX (less than produces a syntax error) 11 | // TypeScript, however, will always produce an error 12 | // To fix this we automatically escape the character. 13 | // https://www.typescriptlang.org/play?#code/JYWwDg9gTgLgBAJQKYEMDG8BmUIjgcilQ3wG4BYAKCrQgDsBneAFSQA94BeOAHgBNgANwB8AcSIoYSKHAAUwgIwAGFQEoeAegEia9JnFYcAzHG78hYiVJmyA3vmH4AvsrWbtwoA 14 | JSXText(path) { 15 | const rawValue = path.node.extra?.raw as string | undefined; 16 | if (rawValue && rawValue.includes(">")) { 17 | path.node.value = path.node.value.replace(/>/g, "{'>'}"); 18 | reporter.unescapedGreaterThan(state.config.filePath, getLoc(path.node)); 19 | } 20 | }, 21 | }); 22 | } 23 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2022 Stripe, Inc. (https://stripe.com) 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. -------------------------------------------------------------------------------- /src/fix/build-diagnostic-message-filter.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, DiagnosticMessageChain } from "ts-morph"; 2 | 3 | function isDiagnosticMessageChain( 4 | diagnostic: Diagnostic | DiagnosticMessageChain 5 | ): diagnostic is DiagnosticMessageChain { 6 | return "getNext" in diagnostic; 7 | } 8 | 9 | export const buildDiagnosticFilter = (targetedErrorMessage: string) => 10 | function diagnosticFilter( 11 | diagnostic: Diagnostic | DiagnosticMessageChain 12 | ): boolean { 13 | const messageText = diagnostic.getMessageText(); 14 | 15 | if (typeof messageText !== "string") { 16 | return diagnosticFilter(messageText); 17 | } 18 | 19 | if (messageText.includes(targetedErrorMessage)) { 20 | return true; 21 | } 22 | 23 | if (isDiagnosticMessageChain(diagnostic)) { 24 | const nextDiagnostic = diagnostic.getNext(); 25 | if (!nextDiagnostic) { 26 | return false; 27 | } 28 | if ( 29 | nextDiagnostic.some((diagnostic) => { 30 | return diagnosticFilter(diagnostic); 31 | }) 32 | ) { 33 | return true; 34 | } 35 | } 36 | 37 | return false; 38 | }; 39 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "forceConsistentCasingInFileNames": true, 5 | "incremental": true, 6 | "isolatedModules": true, 7 | "jsx": "react", 8 | "moduleResolution": "node", 9 | "noEmit": true, 10 | "noFallthroughCasesInSwitch": true, 11 | "noImplicitAny": true, 12 | "noImplicitReturns": false, 13 | "noImplicitThis": true, 14 | "lib": ["dom","dom.iterable", "esnext"], 15 | "resolveJsonModule": true, 16 | "strict": true, 17 | "rootDir": "./src", 18 | "types": [ 19 | "jest", 20 | "node" 21 | ], 22 | "outDir": "./dist", 23 | "target": "es2019", 24 | "module": "commonjs", 25 | "alwaysStrict": true, 26 | "noUnusedLocals": true, 27 | "noUnusedParameters": true, 28 | }, 29 | "include": ["src/**/*"], 30 | "exclude": [ 31 | "src/test/test-files/*", 32 | "src/test/regression/*", 33 | "src/fix/auto-import/__test__/export.ts", 34 | "src/fix/auto-import/__test__/needs-imports.ts", 35 | "src/fix/fix-type-exports/__test__/test-input.ts", 36 | "src/fix/suppressions/__test__/test-input.ts" 37 | ], 38 | } 39 | -------------------------------------------------------------------------------- /src/convert/utils/type-mappings.ts: -------------------------------------------------------------------------------- 1 | export const ReactTypes = { 2 | Node: "ReactNode", 3 | Child: "ReactChild", 4 | Children: "ReactChildren", 5 | Text: "ReactText", 6 | Fragment: "ReactFragment", 7 | FragmentType: "ComponentType", 8 | Portal: "ReactPortal", 9 | NodeArray: "ReactNodeArray", 10 | ElementProps: "ComponentProps", 11 | StatelessFunctionalComponent: `FC`, 12 | } as const; 13 | 14 | export const SyntheticEvents = { 15 | SyntheticEvent: "React.SyntheticEvent", 16 | SyntheticAnimationEvent: "React.AnimationEvent", 17 | SyntheticCompositionEvent: "React.CompositionEvent", 18 | SyntheticClipboardEvent: "React.ClipboardEvent", 19 | SyntheticUIEvent: "React.UIEvent", 20 | SyntheticFocusEvent: "React.FocusEvent", 21 | SyntheticKeyboardEvent: "React.KeyboardEvent", 22 | SyntheticMouseEvent: "React.MouseEvent", 23 | SyntheticDragEvent: "React.DragEvent", 24 | SyntheticWheelEvent: "React.WheelEvent", 25 | SyntheticPointerEvent: "React.PointerEvent", 26 | SyntheticTouchEvent: "React.TouchEvent", 27 | SyntheticTransitionEvent: "React.TransitionEvent", 28 | } as const; 29 | 30 | export const MomentTypes = { 31 | MomentDuration: "Duration", 32 | } as const; 33 | -------------------------------------------------------------------------------- /src/convert/utils/handle-async-function-return-type.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import MigrationReporter from "../../runner/migration-reporter"; 3 | 4 | export function handleAsyncReturnType< 5 | TNodeType extends 6 | | t.FunctionExpression 7 | | t.FunctionDeclaration 8 | | t.ArrowFunctionExpression 9 | | t.ClassMethod 10 | >( 11 | node: TNodeType, 12 | reporter: MigrationReporter, 13 | filePath: string, 14 | loc: t.SourceLocation 15 | ) { 16 | const { returnType } = node; 17 | 18 | if ( 19 | returnType && 20 | t.isTypeAnnotation(returnType) && 21 | t.isGenericTypeAnnotation(returnType.typeAnnotation) && 22 | t.isIdentifier(returnType.typeAnnotation.id) && 23 | !(returnType.typeAnnotation.id.name === "Promise") 24 | ) { 25 | reporter.asyncFunctionReturnType( 26 | filePath, 27 | loc, 28 | returnType.typeAnnotation.id.name 29 | ); 30 | 31 | const typeAnnotation = t.typeAnnotation( 32 | t.genericTypeAnnotation( 33 | t.identifier("Promise"), 34 | t.typeParameterInstantiation([returnType.typeAnnotation]) 35 | ) 36 | ); 37 | 38 | node.returnType = typeAnnotation; 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "root": true, 3 | "extends": [ 4 | "plugin:@typescript-eslint/eslint-recommended", 5 | "plugin:@typescript-eslint/recommended", 6 | "prettier" 7 | ], 8 | "parser": "@typescript-eslint/parser", 9 | "plugins": [ 10 | "jest", 11 | "@typescript-eslint", 12 | "prettier" 13 | ], 14 | "env": { 15 | "jest": true 16 | }, 17 | "rules": { 18 | "no-console": "error", 19 | "no-plusplus": "off", 20 | "default-case": "off", 21 | "no-inner-declarations": "off", 22 | "no-restricted-syntax": "off", 23 | "no-shadow": "off", 24 | "no-use-before-define": "off", 25 | "@typescript-eslint/no-use-before-define": "off", 26 | "no-param-reassign": "off", 27 | "no-continue": "off", 28 | "no-nested-ternary": "off", 29 | "prettier/prettier": "error", 30 | "prefer-destructuring": ["error", { 31 | "array": false, 32 | "object": true 33 | }], 34 | "@typescript-eslint/no-non-null-assertion": "off", 35 | "@typescript-eslint/no-explicit-any": "error" 36 | }, 37 | "ignorePatterns": ["src/fix/fix-type-exports/__test__/test-input.ts", "src/test/test-files/*", "src/fix/suppressions/__test__/test-input.ts"] 38 | } 39 | -------------------------------------------------------------------------------- /src/convert/utils/configurable-type-provider.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | 3 | export class ConfigurableTypeProvider { 4 | private readonly useStrictAnyFunctionType: boolean = false; 5 | 6 | private readonly useStrictAnyObjectType: boolean = false; 7 | 8 | constructor({ 9 | useStrictAnyFunctionType, 10 | useStrictAnyObjectType, 11 | }: { 12 | useStrictAnyObjectType: boolean; 13 | useStrictAnyFunctionType: boolean; 14 | }) { 15 | this.useStrictAnyObjectType = useStrictAnyObjectType; 16 | this.useStrictAnyFunctionType = useStrictAnyFunctionType; 17 | } 18 | 19 | public get flowAnyObjectType(): t.TSType { 20 | if (this.useStrictAnyObjectType) { 21 | // Record 22 | return t.tsTypeReference( 23 | t.identifier("Record"), 24 | t.tsTypeParameterInstantiation([t.tsAnyKeyword(), t.tsAnyKeyword()]) 25 | ); 26 | } else { 27 | return t.tsAnyKeyword(); 28 | } 29 | } 30 | 31 | public get flowAnyFunctionType(): t.TSType { 32 | if (this.useStrictAnyFunctionType) { 33 | return t.tsTypeReference(t.identifier("Function")); 34 | } else { 35 | return t.tsAnyKeyword(); 36 | } 37 | } 38 | } 39 | -------------------------------------------------------------------------------- /src/convert/add-watermark.ts: -------------------------------------------------------------------------------- 1 | import traverse from "@babel/traverse"; 2 | import { types } from "recast"; 3 | import * as t from "@babel/types"; 4 | import { TransformerInput } from "./transformer"; 5 | import { 6 | addCommentsAtHeadOfNode, 7 | addEmptyLineInProgramPath, 8 | } from "./utils/common"; 9 | 10 | /** 11 | * Adds a watermark at the top of a file 12 | * @param state 13 | * @param file 14 | */ 15 | export function addWatermark({ state, file }: TransformerInput) { 16 | traverse(file, { 17 | Program(path) { 18 | addEmptyLineInProgramPath(path); 19 | 20 | // Handles empty files where no node is present 21 | if (path.node.body.length === 0) { 22 | path.node.body.push(t.emptyStatement()); 23 | } 24 | 25 | const rootNode: types.namedTypes.Node = path.node.body[0]; 26 | 27 | addCommentsAtHeadOfNode(rootNode, [ 28 | { 29 | leading: true, 30 | trailing: false, 31 | value: `* ${state.config.watermark} `, 32 | type: "CommentBlock", 33 | }, 34 | { 35 | leading: true, 36 | trailing: false, 37 | value: `* ${state.config.watermarkMessage}`, 38 | type: "CommentBlock", 39 | }, 40 | ]); 41 | }, 42 | }); 43 | } 44 | -------------------------------------------------------------------------------- /src/fix/fix-type-exports/__test__/fix-type-exports.test.ts: -------------------------------------------------------------------------------- 1 | import { fixTypeExports } from ".."; 2 | import { 3 | expectMigrationReporterMethodCalled, 4 | createOutputRecorder, 5 | getTestFixState, 6 | } from "../../../convert/utils/testing"; 7 | 8 | jest.mock("../../../runner/migration-reporter"); 9 | jest.mock("../../../runner/logger"); 10 | 11 | describe("fixTypeExports", () => { 12 | beforeEach(() => { 13 | jest.resetAllMocks(); 14 | }); 15 | 16 | it("fixes mismatched type imports and exports", async () => { 17 | const [results, recordTestResult] = createOutputRecorder(); 18 | 19 | await fixTypeExports( 20 | getTestFixState({ 21 | tsProps: false, 22 | autoSuppressErrors: false, 23 | generateReport: false, 24 | jiraSlug: "", 25 | useIgnore: false, 26 | removeUnused: false, 27 | config: "./src/fix/fix-type-exports/__test__/tsconfig.json", 28 | format: "stdout", 29 | silent: false, 30 | output: "", 31 | autoImport: false, 32 | fixTypeExports: false, 33 | }), 34 | recordTestResult 35 | ); 36 | 37 | expectMigrationReporterMethodCalled(`typeExports`); 38 | 39 | expect(results["test-input.ts"]).toMatchSnapshot(); 40 | }); 41 | }); 42 | -------------------------------------------------------------------------------- /src/convert/private-types.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import traverse from "@babel/traverse"; 3 | import { replaceWith } from "./utils/common"; 4 | import { TransformerInput } from "./transformer"; 5 | 6 | /** 7 | * Flow commonly uses `$` to denote private type members like React$Node. 8 | * This syntax is hard to account for everywhere, so we convert it to `.` at the start. 9 | */ 10 | export function transformPrivateTypes({ 11 | file, 12 | state, 13 | reporter, 14 | }: TransformerInput) { 15 | traverse(file, { 16 | Identifier(path) { 17 | const id = path.node; 18 | const hasPrivateType = 19 | /\w\$\w/.test(id.name) && !state.config.keepPrivateTypes; 20 | const privateReactType = id.name.startsWith("React$"); 21 | const isTypeAnnotation = path.parentPath.type === "GenericTypeAnnotation"; 22 | if ((hasPrivateType || privateReactType) && isTypeAnnotation) { 23 | const [qualification, name] = id.name.split("$"); 24 | replaceWith( 25 | path, 26 | t.qualifiedTypeIdentifier( 27 | t.identifier(name), 28 | t.identifier(qualification) 29 | ), 30 | state.config.filePath, 31 | reporter 32 | ); 33 | } 34 | }, 35 | }); 36 | } 37 | -------------------------------------------------------------------------------- /src/setup/ts-config/ts-config.ts: -------------------------------------------------------------------------------- 1 | // Copy a sample default TsConfig to the current directory 2 | 3 | import { existsSync, lstatSync, writeFileSync } from "fs"; 4 | import { join, relative, resolve } from "path"; 5 | 6 | const ROOT_TSCONFIG_NAME = "tsconfig.json"; 7 | 8 | /** 9 | * Starting in this directory, look up the directory tree until you find a tsconfig.json (unless you reach the top level directory). 10 | */ 11 | function findRootTSConfig(directory = process.cwd()): string { 12 | if (existsSync(join(directory, ROOT_TSCONFIG_NAME))) { 13 | return join(directory, ROOT_TSCONFIG_NAME); 14 | } 15 | 16 | if (resolve(directory) === "/") { 17 | throw new Error("No root Typescript configuration found."); 18 | } 19 | 20 | return findRootTSConfig(join(directory, "..")); 21 | } 22 | 23 | export function defaultTsConfig(conversionPath: string) { 24 | const rootTSConfig = findRootTSConfig(); 25 | 26 | const rootConfig: Record = { 27 | extends: relative(process.cwd(), rootTSConfig), 28 | }; 29 | 30 | if (lstatSync(conversionPath).isDirectory()) { 31 | rootConfig.compilerOptions = { 32 | baseUrl: conversionPath, 33 | }; 34 | } 35 | 36 | writeFileSync( 37 | join(process.cwd(), "tsconfig.json"), 38 | JSON.stringify(rootConfig, null, 2) 39 | ); 40 | } 41 | -------------------------------------------------------------------------------- /src/runner/migration-reporter/formatters/csv-formatter.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { relative } from "path"; 3 | import { stringify } from "csv-stringify/sync"; 4 | import { MigrationReport, MigrationReportItem } from "../migration-reporter"; 5 | 6 | const severityMap = { error: 0, warn: 1, info: 2 } as const; 7 | 8 | const compare = (first: MigrationReportItem, second: MigrationReportItem) => { 9 | if (first.severity !== second.severity) { 10 | return severityMap[first.severity] - severityMap[second.severity]; 11 | } 12 | 13 | if (first.type < second.type) { 14 | return -1; 15 | } 16 | if (first.type > second.type) { 17 | return 1; 18 | } 19 | return 0; 20 | }; 21 | 22 | export function csvFormatter(filePath: string) { 23 | return (report: MigrationReport) => { 24 | const table = [["Type", "Severity", "Message", "Path"]]; 25 | 26 | const items = report.migrationReportItems.sort(compare); 27 | 28 | for (const item of items) { 29 | const pathText = `${relative(process.cwd(), item.filePath)}:${ 30 | item.start.line 31 | }:${item.start.column}`; 32 | table.push([item.type, item.severity, item.message, pathText]); 33 | } 34 | 35 | return fs.promises.writeFile( 36 | relative(process.cwd(), filePath), 37 | stringify(table) 38 | ); 39 | }; 40 | } 41 | -------------------------------------------------------------------------------- /src/runner/state.ts: -------------------------------------------------------------------------------- 1 | import { ConfigurableTypeProvider } from "../convert/utils/configurable-type-provider"; 2 | 3 | export type State = { 4 | // Set this flag if the codemod encounters JSX code 5 | hasJsx: boolean; 6 | 7 | // Set this flag if utility types were encountered, and an import should be added 8 | usedUtils: boolean; 9 | 10 | // Config is used to store immutable configuration 11 | readonly config: { 12 | // The path of the current file that is being converted 13 | readonly filePath: string; 14 | 15 | // If the current file is a test file, which allows looser type conversions 16 | readonly isTestFile: boolean; 17 | 18 | // The watermark tag to use 19 | readonly watermark: string; 20 | 21 | // The message to include with the watermark 22 | readonly watermarkMessage: string; 23 | 24 | // Should we convert JSX Spreads or not? 25 | readonly convertJSXSpreads: boolean; 26 | 27 | // Should we modify the extension of imports? 28 | readonly dropImportExtensions: boolean; 29 | 30 | // Should we keep $ private types 31 | readonly keepPrivateTypes: boolean; 32 | 33 | // Are we going to force TSX extensions 34 | readonly forceTSX: boolean; 35 | 36 | // Should we check flow types or just use any? 37 | readonly disableFlow: boolean; 38 | }; 39 | 40 | // A static type provider for types that may change based on flags 41 | readonly configurableTypeProvider: ConfigurableTypeProvider; 42 | }; 43 | -------------------------------------------------------------------------------- /src/fix/suppressions/__test__/__snapshots__/remove-unused.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`removeUnused does not remove if run twice 1`] = ` 4 | "// Testing removing unused 5 | 6 | const error1: string = 0; 7 | 8 | // Line above suppression 9 | 10 | export const foo = () => { 11 | console.log( 12 | // Other comment 13 | \\"foo\\", 14 | 15 | \\"bar\\", 16 | \\"baz\\" 17 | ); 18 | 19 | const error2: string = 0; 20 | 21 | 22 | return \\"bar\\"; 23 | }; 24 | " 25 | `; 26 | 27 | exports[`removeUnused removes unused error suppressions 1`] = ` 28 | "// Testing removing unused 29 | 30 | const error1: string = 0; 31 | 32 | // Line above suppression 33 | 34 | export const foo = () => { 35 | console.log( 36 | // Other comment 37 | \\"foo\\", 38 | 39 | \\"bar\\", 40 | \\"baz\\" 41 | ); 42 | 43 | const error2: string = 0; 44 | 45 | 46 | return \\"bar\\"; 47 | }; 48 | " 49 | `; 50 | 51 | exports[`removeUnused works with autoSuppress 1`] = ` 52 | "// Testing removing unused 53 | 54 | // @ts-expect-error - TS2322 - Type 'number' is not assignable to type 'string'. 55 | const error1: string = 0; 56 | 57 | // Line above suppression 58 | 59 | export const foo = () => { 60 | console.log( 61 | // Other comment 62 | \\"foo\\", 63 | 64 | \\"bar\\", 65 | \\"baz\\" 66 | ); 67 | 68 | // @ts-expect-error - TS2322 - Type 'number' is not assignable to type 'string'. 69 | const error2: string = 0; 70 | 71 | 72 | return \\"bar\\"; 73 | }; 74 | " 75 | `; 76 | -------------------------------------------------------------------------------- /src/runner/run-setup-command.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import fs from "fs-extra"; 3 | import { SetupCommandCliArgs } from "../cli/arguments"; 4 | import { logger } from "./logger"; 5 | import MigrationReporter from "./migration-reporter"; 6 | import { stdOutFormatter } from "./migration-reporter/formatters/std-out-formatter"; 7 | import { jsonFormatter } from "./migration-reporter/formatters/json-formatter"; 8 | import { installTypescript } from "../setup/install-typescript"; 9 | import { defaultTsConfig } from "../setup/ts-config"; 10 | import { suggestTypes } from "../setup/suggest-types"; 11 | 12 | /** 13 | * Run different setup scripts based on the CLI arguments 14 | * @param argv - CLI arguments 15 | */ 16 | export async function runSetupCommand(argv: SetupCommandCliArgs) { 17 | if (argv.installTS) { 18 | logger.await("Installing Typescript.."); 19 | await installTypescript(); 20 | } 21 | 22 | if (argv.recommendTypeDefinitions) { 23 | const filePathReporter = new MigrationReporter(); 24 | logger.await("Searching for type definitions.."); 25 | await suggestTypes(filePathReporter); 26 | const report = filePathReporter.generateReport(); 27 | const formatter = 28 | argv.format === "json" ? jsonFormatter(argv.output) : stdOutFormatter; 29 | await MigrationReporter.logReport(report, formatter); 30 | } 31 | 32 | if (argv.setupTSConfig) { 33 | if (!fs.existsSync(argv.path)) { 34 | logger.error(`Provided ${path} does not exist.`); 35 | process.exit(1); 36 | } 37 | logger.await("Setting root TSConfig.."); 38 | defaultTsConfig(argv.path); 39 | } 40 | } 41 | -------------------------------------------------------------------------------- /src/convert/remove-flow-comments.ts: -------------------------------------------------------------------------------- 1 | import traverse, { NodePath } from "@babel/traverse"; 2 | import * as t from "@babel/types"; 3 | import { types } from "recast"; 4 | import { TransformerInput } from "./transformer"; 5 | 6 | const flowComments = [ 7 | "@flow", 8 | "$FlowFixMe", 9 | "$FlowIssue", 10 | "$FlowExpectedError", 11 | "$FlowIgnore", 12 | ]; 13 | 14 | /** 15 | * Scan through top level programs, or code blocks and remove Flow-specific comments 16 | */ 17 | const removeComments = ( 18 | path: NodePath | NodePath 19 | ) => { 20 | if (path.node.body.length === 0) { 21 | return; 22 | } 23 | 24 | const nodes: Array = path.node.body; 25 | 26 | for (const rootNode of nodes) { 27 | const { comments } = rootNode; 28 | 29 | rootNode.comments = 30 | comments 31 | ?.filter( 32 | (comment) => !flowComments.some((c) => comment.value.includes(c)) 33 | ) 34 | .map((comment) => { 35 | if (comment.value.includes("@noflow")) { 36 | return { 37 | ...comment, 38 | value: comment.value.replace(/@noflow/, "@ts-nocheck"), 39 | }; 40 | } 41 | 42 | return comment; 43 | }) || rootNode.comments; 44 | } 45 | }; 46 | 47 | /** 48 | * Search the top level program, and blocks like functions and if statements for comments 49 | */ 50 | export function removeFlowComments({ file }: TransformerInput) { 51 | traverse(file, { 52 | Program(path) { 53 | removeComments(path); 54 | }, 55 | BlockStatement(path) { 56 | removeComments(path); 57 | }, 58 | }); 59 | } 60 | -------------------------------------------------------------------------------- /src/convert/add-watermark.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import { stateBuilder, watermarkTransform } from "./utils/testing"; 3 | 4 | describe("watermarks", () => { 5 | it("adds a watermark at the top", async () => { 6 | const src = ``; 7 | const expected = dedent`/** @typescriptify */ 8 | /** 9 | THIS FILE IS AUTOMATICALLY GENERATED. Do not edit this file. 10 | If you want to manually write flow types for this file, 11 | remove the @typescriptify annotation and this comment block. 12 | */`; 13 | expect((await watermarkTransform(src)).trim()).toBe(expected.trim()); 14 | }); 15 | 16 | it("Preserves existing comments", async () => { 17 | const src = dedent`// copyright stripe 2021 18 | const a = 1 + 1;`; 19 | const expected = dedent`/** @typescriptify */ 20 | /** 21 | THIS FILE IS AUTOMATICALLY GENERATED. Do not edit this file. 22 | If you want to manually write flow types for this file, 23 | remove the @typescriptify annotation and this comment block. 24 | */ 25 | 26 | 27 | // copyright stripe 2021 28 | const a = 1 + 1;`; 29 | expect((await watermarkTransform(src)).trim()).toBe(expected.trim()); 30 | }); 31 | 32 | it("Respects state", async () => { 33 | const src = ``; 34 | const expected = dedent`/** @test */ 35 | /** Test message*/`; 36 | 37 | const state = stateBuilder({ 38 | config: { 39 | filePath: "./fake/test.js", 40 | isTestFile: true, 41 | watermark: "@test", 42 | watermarkMessage: `Test message`, 43 | }, 44 | }); 45 | expect((await watermarkTransform(src, state)).trim()).toBe(expected.trim()); 46 | }); 47 | }); 48 | -------------------------------------------------------------------------------- /src/convert/jsx-spread/get-component-type.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | 3 | /** 4 | * Convert HTML tag names to the TS type for it 5 | * @param tag - The string of the HTML tag e.g. `div` 6 | * @returns {string} - The TS type for that tag 7 | */ 8 | function translateHTMLComponent(tag: string) { 9 | if (tag.match(/h\d/)) { 10 | return "HTMLHeadingElement"; 11 | } 12 | 13 | if (tag === "fieldset") { 14 | return "HTMLFieldSetElement"; 15 | } 16 | 17 | if (tag === "a") { 18 | return "HTMLAnchorElement"; 19 | } 20 | 21 | if (tag === "td") { 22 | return "HTMLTableCellElement"; 23 | } 24 | 25 | if (tag === "tr") { 26 | return "HTMLTableRowElement"; 27 | } 28 | 29 | return `HTML${tag.charAt(0).toUpperCase()}${tag.slice(1)}Element`; 30 | } 31 | 32 | export function getComponentType(targetTag: string) { 33 | if (targetTag.charAt(0) === targetTag.charAt(0).toLowerCase()) { 34 | const qualifiedTypeAnnotation = t.tsQualifiedName( 35 | t.identifier("React"), 36 | t.identifier("HTMLProps") 37 | ); 38 | 39 | const myTypeLiteral = t.tsTypeReference( 40 | t.identifier(translateHTMLComponent(targetTag)) 41 | ); 42 | 43 | return t.tsTypeReference( 44 | qualifiedTypeAnnotation, 45 | t.tsTypeParameterInstantiation([myTypeLiteral]) 46 | ); 47 | } 48 | 49 | const typeOfComponentAnnotation = t.tsTypeQuery(t.identifier(targetTag)); 50 | const qualifiedTypeAnnotation = t.tsQualifiedName( 51 | t.identifier("Flow"), 52 | t.identifier("ComponentProps") 53 | ); 54 | 55 | return t.tsTypeReference( 56 | qualifiedTypeAnnotation, 57 | t.tsTypeParameterInstantiation([typeOfComponentAnnotation]) 58 | ); 59 | } 60 | -------------------------------------------------------------------------------- /src/convert/jsx.test.ts: -------------------------------------------------------------------------------- 1 | import { 2 | transform, 3 | expectMigrationReporterMethodCalled, 4 | expectMigrationReporterMethodNotCalled, 5 | } from "./utils/testing"; 6 | 7 | jest.mock("../runner/migration-reporter/migration-reporter.ts"); 8 | 9 | describe("transforms JSX", () => { 10 | afterEach(() => { 11 | jest.clearAllMocks(); 12 | }); 13 | it("escapes greater than symbols", async () => { 14 | const src = `const Text =
Greater (>1000)
`; 15 | const expected = `const Text =
Greater ({'>'}1000)
`; 16 | expect(await transform(src)).toBe(expected); 17 | expectMigrationReporterMethodCalled("unescapedGreaterThan"); 18 | }); 19 | it("does not escape normal strings", async () => { 20 | const src = `const text = "Greater (>1000)"`; 21 | expect(await transform(src)).toBe(src); 22 | expectMigrationReporterMethodNotCalled("unescapedGreaterThan"); 23 | }); 24 | it("does not escape already escaped", async () => { 25 | const src = `const Text =
Greater ({'>'}1000)
`; 26 | expect(await transform(src)).toBe(src); 27 | expectMigrationReporterMethodNotCalled("unescapedGreaterThan"); 28 | }); 29 | it("does not escape gt", async () => { 30 | const src = `const Text =
test <host-type>.test.com
`; 31 | expect(await transform(src)).toBe(src); 32 | expectMigrationReporterMethodNotCalled("unescapedGreaterThan"); 33 | }); 34 | it("escapes multiple symbols", async () => { 35 | const src = `const Text =
Greater(>) Greater(>)
`; 36 | const expected = `const Text =
Greater({'>'}) Greater({'>'})
`; 37 | expect(await transform(src)).toBe(expected); 38 | expectMigrationReporterMethodCalled("unescapedGreaterThan"); 39 | }); 40 | }); 41 | -------------------------------------------------------------------------------- /src/fix/suppressions/__test__/__snapshots__/auto-suppress.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`autoSuppressErrors adds suppressions to suppressable errors 1`] = ` 4 | "// Testing removing unused 5 | 6 | // @ts-expect-error - TS2322 - Type 'number' is not assignable to type 'string'. 7 | const error1: string = 0; 8 | 9 | // Line above suppression 10 | // @ts-expect-error not actually error 11 | export const foo = () => { 12 | console.log( 13 | // Other comment 14 | \\"foo\\", 15 | // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type '{ customer: string; ephemeralKey: string; } | null'. 16 | \\"bar\\", 17 | \\"baz\\" 18 | ); 19 | 20 | // @ts-expect-error - TS2322 - Type 'number' is not assignable to type 'string'. 21 | const error2: string = 0; 22 | 23 | /* @ts-expect-error not actually error */ 24 | return \\"bar\\"; 25 | }; 26 | " 27 | `; 28 | 29 | exports[`autoSuppressErrors does not add if run twice 1`] = ` 30 | "// Testing removing unused 31 | 32 | // @ts-expect-error - TS2322 - Type 'number' is not assignable to type 'string'. 33 | const error1: string = 0; 34 | 35 | // Line above suppression 36 | // @ts-expect-error not actually error 37 | export const foo = () => { 38 | console.log( 39 | // Other comment 40 | \\"foo\\", 41 | // @ts-expect-error - TS2345 - Argument of type 'unknown' is not assignable to parameter of type '{ customer: string; ephemeralKey: string; } | null'. 42 | \\"bar\\", 43 | \\"baz\\" 44 | ); 45 | 46 | // @ts-expect-error - TS2322 - Type 'number' is not assignable to type 'string'. 47 | const error2: string = 0; 48 | 49 | /* @ts-expect-error not actually error */ 50 | return \\"bar\\"; 51 | }; 52 | " 53 | `; 54 | -------------------------------------------------------------------------------- /flow.d.ts: -------------------------------------------------------------------------------- 1 | // Type definitions for mapping Flow types to TypeScript 2 | // Project: flow-to-typescript-codemod 3 | 4 | type SetComplement = A extends B ? never : A; 5 | 6 | type DefaultProps = T extends { defaultProps: infer D } ? D : {}; 7 | 8 | type OmitDefaultProps = Omit & 9 | Partial>> & 10 | Partial>>; 11 | 12 | type HasComponentProps> = T extends ( 13 | prop: infer P 14 | ) => React.ReactElement 15 | ? OmitDefaultProps 16 | : never; 17 | export declare namespace Flow { 18 | // Abstract Component utility type 19 | // https://flow.org/en/docs/react/types/#toc-react-abstractcomponent 20 | type AbstractComponent = React.ComponentType< 21 | React.PropsWithoutRef & React.RefAttributes 22 | >; 23 | 24 | // Class utility type 25 | // https://flow.org/en/docs/types/utilities/#toc-class 26 | // https://github.com/piotrwitek/utility-types/blob/df2502ef504c4ba8bd9de81a45baef112b7921d0/src/utility-types.ts#L158 27 | type Class = new (...args: any[]) => T; 28 | 29 | // $Diff utility type 30 | // https://flow.org/en/docs/types/utilities/#toc-diff 31 | // https://github.com/piotrwitek/utility-types/blob/df2502ef504c4ba8bd9de81a45baef112b7921d0/src/utility-types.ts#L50 32 | type Diff = Pick< 33 | T, 34 | SetComplement 35 | >; 36 | 37 | type ComponentProps = T extends 38 | | React.ComponentType 39 | | React.Component 40 | ? JSX.LibraryManagedAttributes 41 | : HasComponentProps; 42 | 43 | type ObjMap< 44 | O extends Record, 45 | F extends (...args: any[]) => any 46 | > = { [P in keyof O]: ReturnType }; 47 | } 48 | -------------------------------------------------------------------------------- /src/runner/run-worker-async.ts: -------------------------------------------------------------------------------- 1 | import { ConvertCommandCliArgs } from "../cli/arguments"; 2 | import MigrationReporter from "./migration-reporter"; 3 | import { processBatchAsync } from "./process-batch"; 4 | import { logger } from "./logger"; 5 | 6 | /** 7 | * Start a timer to check in with child processes 8 | */ 9 | function startHeartbeat() { 10 | let currentTimer = 0; 11 | 12 | function _localHeartbeat() { 13 | currentTimer = setTimeout(() => { 14 | process.send!({ type: "heartbeat" }); 15 | _localHeartbeat(); 16 | }); 17 | } 18 | 19 | _localHeartbeat(); 20 | 21 | return function cancelHeartbeat() { 22 | clearTimeout(currentTimer); 23 | }; 24 | } 25 | 26 | /** 27 | * Run an async process, requesting new batches as they are completed 28 | * @param options CLI arguments for Convert command 29 | */ 30 | export async function runWorkerAsync(options: ConvertCommandCliArgs) { 31 | const reporter = new MigrationReporter(); 32 | 33 | process.on("message", (message) => { 34 | const cancelHeartbeat = startHeartbeat(); 35 | switch (message.type) { 36 | // Process a batch of files and ask for more... 37 | case "batch": { 38 | processBatchAsync(reporter, message.batch, options).then( 39 | () => { 40 | process.send!({ type: "next" }); 41 | cancelHeartbeat(); 42 | }, 43 | (error) => { 44 | cancelHeartbeat(); 45 | logger.error(error); 46 | process.exit(1); 47 | } 48 | ); 49 | break; 50 | } 51 | 52 | // We were asked for a report, so send one back! 53 | case "report": { 54 | cancelHeartbeat(); 55 | process.send!({ 56 | type: "report", 57 | report: reporter.generateReport(), 58 | }); 59 | break; 60 | } 61 | } 62 | }); 63 | } 64 | -------------------------------------------------------------------------------- /src/fix/suppressions/__test__/auto-suppress.test.ts: -------------------------------------------------------------------------------- 1 | import { autoSuppressErrors } from "../auto-suppress"; 2 | import { 3 | createOutputRecorder, 4 | getTestFixState, 5 | } from "../../../convert/utils/testing"; 6 | 7 | jest.mock("../../../runner/migration-reporter"); 8 | jest.mock("../../../runner/logger"); 9 | 10 | describe("autoSuppressErrors", () => { 11 | beforeEach(() => { 12 | jest.resetAllMocks(); 13 | }); 14 | 15 | it("adds suppressions to suppressable errors", async () => { 16 | const [results, recordTestResult] = createOutputRecorder(); 17 | 18 | await autoSuppressErrors( 19 | getTestFixState({ 20 | tsProps: false, 21 | autoSuppressErrors: false, 22 | generateReport: false, 23 | jiraSlug: "", 24 | useIgnore: false, 25 | removeUnused: false, 26 | config: "./src/fix/suppressions/__test__/tsconfig.json", 27 | format: "stdout", 28 | silent: false, 29 | output: "", 30 | autoImport: false, 31 | fixTypeExports: false, 32 | }), 33 | recordTestResult 34 | ); 35 | 36 | expect(results["test-input.ts"]).toMatchSnapshot(); 37 | }); 38 | 39 | it("does not add if run twice", async () => { 40 | const [results, recordTestResult] = createOutputRecorder(); 41 | const state = getTestFixState({ 42 | tsProps: false, 43 | autoSuppressErrors: false, 44 | generateReport: false, 45 | jiraSlug: "", 46 | useIgnore: false, 47 | removeUnused: false, 48 | config: "./src/fix/suppressions/__test__/tsconfig.json", 49 | format: "stdout", 50 | silent: false, 51 | output: "", 52 | autoImport: false, 53 | fixTypeExports: false, 54 | }); 55 | 56 | await autoSuppressErrors(state, recordTestResult); 57 | 58 | await autoSuppressErrors(state, recordTestResult); 59 | 60 | expect(results["test-input.ts"]).toMatchSnapshot(); 61 | }); 62 | }); 63 | -------------------------------------------------------------------------------- /src/runner/migration-reporter/formatters/std-out-formatter.ts: -------------------------------------------------------------------------------- 1 | import path from "path"; 2 | import chalk from "chalk"; 3 | import { logger } from "../../logger"; 4 | import { 5 | MigrationReportItem, 6 | MigrationReport, 7 | MigrationReportItemSeverity, 8 | } from "../migration-reporter"; 9 | 10 | const severityLoggerMap: Record< 11 | MigrationReportItemSeverity, 12 | (message?: string) => void 13 | > = { 14 | [MigrationReportItemSeverity.info]: logger.info, 15 | [MigrationReportItemSeverity.warn]: logger.warn, 16 | [MigrationReportItemSeverity.error]: logger.error, 17 | }; 18 | 19 | export async function stdOutFormatter(report: MigrationReport) { 20 | const groupedReport = report.migrationReportItems.reduce((accum, current) => { 21 | const typeBucket = accum[current.type] ?? []; 22 | typeBucket.push(current); 23 | accum[current.type] = typeBucket; 24 | 25 | return accum; 26 | }, {} as Record>); 27 | 28 | logger.scope("typescriptify", "report"); 29 | logger.log(); 30 | logger.log(chalk.underline.bgBlue("Migration Report")); 31 | 32 | if (report.migrationReportItems.length === 0) { 33 | logger.complete("No Items to Report"); 34 | return; 35 | } 36 | 37 | Object.keys(groupedReport).forEach((reportKey) => { 38 | logger.log(); 39 | logger.log(`${chalk.underline.yellow(reportKey)}`); 40 | groupedReport[reportKey].forEach((reportItem) => { 41 | const log = severityLoggerMap[reportItem.severity]; 42 | const { message } = reportItem; 43 | const pathText = `${path.relative(process.cwd(), reportItem.filePath)}:${ 44 | reportItem.start.line 45 | }:${reportItem.start.column}`; 46 | log(`${message} ${chalk.dim(`(${pathText})`)}`); 47 | }); 48 | }); 49 | 50 | logger.log(`\n`); 51 | logger.complete( 52 | `Found ${report.totals.info} logs, ${report.totals.warn} warnings, and ${report.totals.error} errors.` 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/fix/auto-import/index.ts: -------------------------------------------------------------------------------- 1 | import { SourceFile } from "ts-morph"; 2 | import MigrationReporter from "../../runner/migration-reporter"; 3 | import { stdOutFormatter } from "../../runner/migration-reporter/formatters/std-out-formatter"; 4 | import { jsonFormatter } from "../../runner/migration-reporter/formatters/json-formatter"; 5 | import { logger } from "../../runner/logger"; 6 | import { FixCommandState, getDiagnostics } from "../state"; 7 | 8 | /** 9 | * Use the TypeScript compiler's auto-import feature to try to fix missing imports. 10 | * Warning: this can be slow in large codebases. 11 | */ 12 | export async function autoImport( 13 | { argv, migrationReporter, project }: FixCommandState, 14 | write = true 15 | ) { 16 | logger.info(`Finding files with potential import errors.`); 17 | const diagnostics = getDiagnostics(project); 18 | const sourceFileMap = diagnostics 19 | .filter((diagnostic) => diagnostic.getCode() === 2304) 20 | .reduce((sourceFileMap, error) => { 21 | const sourceFile = error.getSourceFile(); 22 | if (!sourceFile || sourceFileMap.has(sourceFile.getFilePath())) { 23 | return sourceFileMap; 24 | } 25 | 26 | return sourceFileMap.set(sourceFile.getFilePath(), sourceFile); 27 | }, new Map()); 28 | 29 | logger.info( 30 | `Attempting to fix import errors with auto-import. This may take a while..` 31 | ); 32 | sourceFileMap.forEach((sourceFile) => { 33 | migrationReporter.autoImport(sourceFile.getFilePath()); 34 | sourceFile.fixMissingImports(); 35 | try { 36 | if (write) { 37 | sourceFile.saveSync(); 38 | } 39 | } catch (e) { 40 | logger.warn( 41 | `Error when saving suppressed source file. Ensure that node_modules is not being type checked by your TSConfig. Error: ${e}.` 42 | ); 43 | } 44 | }); 45 | 46 | logger.info(`Done auto-import.`); 47 | 48 | const formatter = 49 | argv.format === "json" ? jsonFormatter(argv.output) : stdOutFormatter; 50 | await MigrationReporter.logReport( 51 | migrationReporter.generateReport(), 52 | formatter 53 | ); 54 | } 55 | -------------------------------------------------------------------------------- /src/convert/migrate/type-parameter.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import MigrationReporter from "../../runner/migration-reporter"; 3 | import { inheritLocAndComments } from "../utils/common"; 4 | import { migrateType } from "./type"; 5 | import { State } from "../../runner/state"; 6 | import { MetaData } from "./metadata"; 7 | 8 | export function migrateTypeParameterDeclaration( 9 | reporter: MigrationReporter, 10 | state: State, 11 | flowTypeParameters: t.TypeParameterDeclaration 12 | ): t.TSTypeParameterDeclaration { 13 | const params = flowTypeParameters.params.map((flowTypeParameter) => { 14 | // ReadOnlyMap 15 | // https://flow.org/en/docs/lang/variance/ 16 | if (flowTypeParameter.variance !== null) { 17 | reporter.typeParameterWithVariance( 18 | state.config.filePath, 19 | flowTypeParameter.loc! 20 | ); 21 | } 22 | const tsTypeParameter = t.tsTypeParameter( 23 | flowTypeParameter.bound 24 | ? migrateType(reporter, state, flowTypeParameter.bound.typeAnnotation) 25 | : null, 26 | flowTypeParameter.default 27 | ? migrateType(reporter, state, flowTypeParameter.default, { 28 | isTypeParameter: true, 29 | }) 30 | : null, 31 | flowTypeParameter.name 32 | ); 33 | tsTypeParameter.name = flowTypeParameter.name; 34 | inheritLocAndComments(flowTypeParameter, tsTypeParameter); 35 | return tsTypeParameter; 36 | }); 37 | const tsTypeParameters = t.tsTypeParameterDeclaration(params); 38 | inheritLocAndComments(flowTypeParameters, tsTypeParameters); 39 | return tsTypeParameters; 40 | } 41 | 42 | export function migrateTypeParameterInstantiation( 43 | reporter: MigrationReporter, 44 | state: State, 45 | flowTypeParameters: t.TypeParameterInstantiation, 46 | metaData?: MetaData 47 | ): t.TSTypeParameterInstantiation { 48 | const params = flowTypeParameters.params.map((flowTypeParameter) => { 49 | return migrateType(reporter, state, flowTypeParameter, metaData); 50 | }); 51 | const tsTypeParameters = t.tsTypeParameterInstantiation(params); 52 | inheritLocAndComments(flowTypeParameters, tsTypeParameters); 53 | return tsTypeParameters; 54 | } 55 | -------------------------------------------------------------------------------- /src/convert/private-types.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import { transform, stateBuilder } from "./utils/testing"; 3 | 4 | describe("transform type annotations", () => { 5 | // React 6 | it("Converts React$Node to React.ReactNode", async () => { 7 | const src = `type Foo = React$Node;`; 8 | const expected = `type Foo = React.ReactNode;`; 9 | expect(await transform(src)).toBe(expected); 10 | }); 11 | 12 | it("converts React$Context", async () => { 13 | const src = `const Context: React$Context = React.createContext(T);`; 14 | const expected = `const Context: React.Context = React.createContext(T);`; 15 | expect(await transform(src)).toBe(expected); 16 | }); 17 | 18 | it("converts React$Element", async () => { 19 | const src = `const Component = (props: Props): React$Element => {return
};`; 20 | const expected = `const Component = (props: Props): React.Element => {return
};`; 21 | expect(await transform(src)).toBe(expected); 22 | }); 23 | 24 | it("converts moment$", async () => { 25 | const src = `type Test = moment$Test;`; 26 | const expected = `type Test = moment.Test;`; 27 | expect(await transform(src)).toBe(expected); 28 | }); 29 | 30 | it("converts Flow namespaces", async () => { 31 | const src = `const Component = (props: Props): Any$Thing => {return
};`; 32 | const expected = `const Component = (props: Props): Any.Thing => {return
};`; 33 | expect(await transform(src)).toBe(expected); 34 | }); 35 | 36 | it("does not convert Flow namespace when keepPrivateTypes is set", async () => { 37 | const state = stateBuilder({ 38 | config: { 39 | keepPrivateTypes: true, 40 | }, 41 | }); 42 | const src = `const Component = (props: Props): Any$Thing => {return
};`; 43 | expect(await transform(src, state)).toBe(src); 44 | }); 45 | 46 | it("Converts private types in type parameter instantiations", async () => { 47 | const src = dedent` 48 | // @flow 49 | const test = React.useRef>(null); 50 | `; 51 | const expected = dedent` 52 | const test = React.useRef>(null); 53 | `; 54 | expect(await transform(src)).toBe(expected); 55 | }); 56 | }); 57 | -------------------------------------------------------------------------------- /src/setup/suggest-types/suggest-types.ts: -------------------------------------------------------------------------------- 1 | import { exec } from "child_process"; 2 | import fs from "fs"; 3 | import path from "path"; 4 | import MigrationReporter from "../../runner/migration-reporter"; 5 | 6 | const normalizeName = (name: string) => 7 | /^@/.test(name) ? name.slice(1).split("/").join("__") : name; 8 | 9 | /** 10 | * Parse package.json and node_modules to find packages missing types 11 | */ 12 | export function suggestTypes(reporter: MigrationReporter) { 13 | const nodeModules = path.join(process.cwd(), "node_modules"); 14 | const rawPackageJson = fs.readFileSync( 15 | path.join(process.cwd(), "package.json"), 16 | "utf8" 17 | ); 18 | const myPackageJson = JSON.parse(rawPackageJson); 19 | // Go through each package, and determine if it has a type definitions defined in its package.json 20 | const maybeNeedTypes = Object.keys(myPackageJson.dependencies) 21 | .filter((key) => { 22 | const pathParts = key.split("/"); 23 | const modulePackageJson = path.join( 24 | nodeModules, 25 | ...pathParts, 26 | "package.json" 27 | ); 28 | const rawData = fs.readFileSync(modulePackageJson, "utf8"); 29 | const parsedJson = JSON.parse(rawData); 30 | return !parsedJson.typings && !parsedJson.types; 31 | }) 32 | // For all remaining files, figure out if there is a `@types` definition available for our specified version 33 | .map((key) => { 34 | const normalName = normalizeName(key); 35 | return `@types/${normalName}@${myPackageJson.dependencies[key]}`; 36 | }) 37 | // Find out if the package exists 38 | .map((pkgName) => 39 | new Promise((resolve, reject) => { 40 | exec(`yarn info ${pkgName}`, (err, _stdout, stderr) => { 41 | if (err) { 42 | reject(err); 43 | return; 44 | } 45 | if (stderr) { 46 | reject(stderr); 47 | return; 48 | } 49 | resolve(pkgName); 50 | }); 51 | }).catch(() => null) 52 | ); 53 | 54 | // Print out packages that need types, and have something available in `@types` 55 | return Promise.all(maybeNeedTypes) 56 | .then((results) => results.filter((val) => val != null)) 57 | .then((results) => results.join(" ")) 58 | .then((result) => reporter.maybeNeedTypes(result)); 59 | } 60 | -------------------------------------------------------------------------------- /src/convert/migrate/function-parameter.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import MigrationReporter from "../../runner/migration-reporter"; 3 | import { State } from "../../runner/state"; 4 | import { buildTSIdentifier, inheritLocAndComments } from "../utils/common"; 5 | import { migrateType } from "./type"; 6 | 7 | /** 8 | * Scan through function parameters and convert them 9 | * Optional parameters behave differently between the systems 10 | */ 11 | export function migrateFunctionParameters( 12 | reporter: MigrationReporter, 13 | state: State, 14 | flowType: t.FunctionTypeAnnotation 15 | ) { 16 | function isOptional(param: t.FunctionTypeParam) { 17 | return ( 18 | param.optional || 19 | param.typeAnnotation.type === "NullableTypeAnnotation" || 20 | param.typeAnnotation.type === "AnyTypeAnnotation" 21 | ); 22 | } 23 | const params = flowType.params.map( 24 | (flowParam, i) => { 25 | const tsParam = buildTSIdentifier( 26 | // If a Flow function type argument doesn’t have a name we call it `argN`. This 27 | // matches the JavaScript convention of calling function inputs “arguments”. 28 | flowParam.name ? flowParam.name.name : `arg${i + 1}`, 29 | !flowType.params.some( 30 | (p, j) => 31 | // if the remaining array has any non-optional parameters, then do no mark as optional 32 | j > i && !isOptional(p) 33 | ) && isOptional(flowParam), 34 | t.tsTypeAnnotation( 35 | migrateType(reporter, state, flowParam.typeAnnotation) 36 | ) 37 | ); 38 | inheritLocAndComments(flowParam, tsParam); 39 | return tsParam; 40 | } 41 | ); 42 | if (flowType.rest) { 43 | // If a Flow rest element doesn’t have a name we call it `rest`. 44 | const tsRestParam = t.restElement( 45 | flowType.rest.name || t.identifier("rest") 46 | ); 47 | tsRestParam.typeAnnotation = t.tsTypeAnnotation( 48 | migrateType(reporter, state, flowType.rest.typeAnnotation) 49 | ); 50 | inheritLocAndComments(flowType.rest, tsRestParam); 51 | params.push(tsRestParam); 52 | 53 | // Technically, Flow rest parameters can be optional (`(...rest?: T[]) => void`), 54 | // but what does that even mean? We choose to ignore that. 55 | } 56 | 57 | return params; 58 | } 59 | -------------------------------------------------------------------------------- /src/convert/transform-runners.ts: -------------------------------------------------------------------------------- 1 | import { hasJSX } from "./utils/common"; 2 | import { addImports } from "./add-imports"; 3 | import { addWatermark } from "./add-watermark"; 4 | import { transformJSX } from "./jsx"; 5 | import { transformDeclarations } from "./declarations"; 6 | import { transformExpressions } from "./expressions"; 7 | import { transformJsxSpread } from "./jsx-spread/jsx-spread"; 8 | import { transformPatterns } from "./patterns"; 9 | import { transformPrivateTypes } from "./private-types"; 10 | import { TransformerInput, Transformer } from "./transformer"; 11 | import { transformTypeAnnotations } from "./type-annotations"; 12 | import { removeFlowComments } from "./remove-flow-comments"; 13 | 14 | const standardTransformRunnerFactory = (transformer: Transformer) => { 15 | return (transformerInput: TransformerInput) => { 16 | return transformer(transformerInput); 17 | }; 18 | }; 19 | 20 | export const hasJsxTransformRunner: Transformer = ( 21 | transformerInput: TransformerInput 22 | ) => { 23 | transformerInput.state.hasJsx = hasJSX(transformerInput); 24 | }; 25 | 26 | export const privateTypeTransformRunner: Transformer = 27 | standardTransformRunnerFactory(transformPrivateTypes); 28 | 29 | export const expressionTransformRunner: Transformer = 30 | standardTransformRunnerFactory(transformExpressions); 31 | 32 | export const jsxTransformRunner: Transformer = 33 | standardTransformRunnerFactory(transformJSX); 34 | 35 | export const declarationsTransformRunner: Transformer = async ( 36 | transformerInput: TransformerInput 37 | ) => { 38 | await transformDeclarations(transformerInput); 39 | }; 40 | 41 | export const typeAnnotationTransformRunner: Transformer = 42 | standardTransformRunnerFactory(transformTypeAnnotations); 43 | 44 | export const patternTransformRunner: Transformer = 45 | standardTransformRunnerFactory(transformPatterns); 46 | 47 | export const importTransformRunner: Transformer = 48 | standardTransformRunnerFactory(addImports); 49 | 50 | export const watermarkTransformRunner: Transformer = 51 | standardTransformRunnerFactory(addWatermark); 52 | 53 | export const jsxSpreadTransformRunner: Transformer = 54 | standardTransformRunnerFactory(transformJsxSpread); 55 | 56 | export const removeFlowCommentTransformRunner: Transformer = 57 | standardTransformRunnerFactory(removeFlowComments); 58 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "flow-to-typescript-codemod", 3 | "version": "0.0.0", 4 | "private": true, 5 | "description": "Stripe's codemod for converting Flow to TypeScript.", 6 | "repository": "https://github.com/stripe-archive/flow-to-typescript-codemod", 7 | "license": "MIT", 8 | "author": "Stripe, Inc.", 9 | "types": "./flow.d.ts", 10 | "bin": { 11 | "typescriptify": "./bin.js" 12 | }, 13 | "files": [ 14 | "dist", 15 | "flow.d.ts", 16 | "bin.js" 17 | ], 18 | "scripts": { 19 | "build": "./esBuild.js", 20 | "dev": "yarn build && node ./dist/index.js", 21 | "typescriptify": "yarn build && node ./dist/index.js", 22 | "lint": "eslint --ext ts src", 23 | "prepare": "yarn patch-package", 24 | "prettier": "prettier --write 'src/**/*.{js,jsx,mdx,ts,tsx}' !src/test/test_files/*.{js,jsx,ts,tsx}", 25 | "test": "jest", 26 | "types": "tsc -b ." 27 | }, 28 | "resolutions": { 29 | "@babel/parser": "7.16.0", 30 | "@babel/traverse": "7.16.0", 31 | "@babel/types": "7.16.0", 32 | "code-block-writer": "10", 33 | "minimist": "1.2.6" 34 | }, 35 | "devDependencies": { 36 | "@babel/parser": "7.16.0", 37 | "@babel/traverse": "7.16.0", 38 | "@babel/types": "7.16.0", 39 | "@types/babel__traverse": "^7.0.7", 40 | "@types/csv-stringify": "^3.1.0", 41 | "@types/dedent": "^0.7.0", 42 | "@types/fs-extra": "^8.0.0", 43 | "@types/jest": "^27.0.2", 44 | "@types/minimist": "^1.2.2", 45 | "@types/node": "^12.7.1", 46 | "@types/react": "^16.14.18", 47 | "@types/signale": "^1.4.2", 48 | "@types/yargs": "^17.0.5", 49 | "@typescript-eslint/eslint-plugin": "^5.10.1", 50 | "@typescript-eslint/parser": "^5.10.1", 51 | "chalk": "^2.4.2", 52 | "csv-stringify": "^6.0.4", 53 | "dedent": "^0.7.0", 54 | "esbuild": "0.14.28", 55 | "eslint": "^7.32.0", 56 | "eslint-config-prettier": "^8.3.0", 57 | "eslint-plugin-import": "^2.25.2", 58 | "eslint-plugin-jest": "^25.2.2", 59 | "eslint-plugin-prettier": "^4.0.0", 60 | "flow": "^0.2.3", 61 | "flow-bin": "^0.142.0", 62 | "fs-extra": "^8.1.0", 63 | "ignore": "^5.1.9", 64 | "jest": "^26.0.0", 65 | "jest-junit": "^12.0.0", 66 | "patch-package": "^6.4.7", 67 | "prettier": "^2.4.1", 68 | "recast": "^0.20.4", 69 | "signale": "^1.4.0", 70 | "simple-git": "^3.5.0", 71 | "ts-jest": "^26.0.0", 72 | "ts-morph": "^13.0.2", 73 | "typescript": "4.6.3", 74 | "yargs": "^17.2.1" 75 | }, 76 | "dependencies": {} 77 | } 78 | -------------------------------------------------------------------------------- /src/fix/generate-report/index.ts: -------------------------------------------------------------------------------- 1 | import * as fs from "fs"; 2 | import { stringify } from "csv-stringify/sync"; 3 | import { relative } from "path"; 4 | import { logger } from "../../runner/logger"; 5 | import { isDiagnosticSuppressible } from "../insuppressible-errors"; 6 | import { FixCommandState, getDiagnostics } from "../state"; 7 | 8 | export type ReportRow = [ 9 | code: string, 10 | message: string, 11 | source: string, 12 | path: string, 13 | suppressible: string 14 | ]; 15 | 16 | export const compare = (first: ReportRow, second: ReportRow) => { 17 | const firstCode = parseInt(first[0], 10); 18 | const secondCode = parseInt(second[0], 10); 19 | if (firstCode === secondCode) { 20 | if (first[3] < second[3]) { 21 | return -1; 22 | } 23 | if (first[3] > second[3]) { 24 | return 1; 25 | } 26 | return 0; 27 | } 28 | 29 | return firstCode - secondCode; 30 | }; 31 | 32 | export function generateReport({ argv, project }: FixCommandState) { 33 | const outputFile = relative( 34 | process.cwd(), 35 | argv.output || `migration-report.csv` 36 | ); 37 | logger.info(`Generating error report to ${outputFile}`); 38 | const table: Array = []; 39 | 40 | const diagnostics = getDiagnostics(project); 41 | 42 | diagnostics.forEach((error) => { 43 | const sourceFile = error.getSourceFile(); 44 | const errorLineNumber = error.getLineNumber(); 45 | 46 | if (!sourceFile || !errorLineNumber) { 47 | return; 48 | } 49 | // Get the source code for the line with the error 50 | const errorSource = sourceFile.getFullText().split(`\n`)[ 51 | errorLineNumber - 1 52 | ]; 53 | const filePath = sourceFile.compilerNode.fileName; 54 | const pathText = `${relative(process.cwd(), filePath)}:${errorLineNumber}`; 55 | 56 | const messageNode = error.getMessageText(); 57 | if (typeof messageNode === "string") { 58 | table.push([ 59 | error.getCode().toString(), 60 | messageNode, 61 | errorSource, 62 | pathText, 63 | String(isDiagnosticSuppressible(error)), 64 | ]); 65 | } else { 66 | // The message can be a DiagnosticMessageChain object instead of a string 67 | table.push([ 68 | error.getCode().toString(), 69 | messageNode.getMessageText(), 70 | errorSource, 71 | pathText, 72 | String(isDiagnosticSuppressible(error)), 73 | ]); 74 | } 75 | }); 76 | 77 | const report = table.sort(compare); 78 | report.unshift([ 79 | "Error Code", 80 | "Message", 81 | "Source", 82 | "File Path", 83 | "Suppressible", 84 | ]); 85 | 86 | return fs.promises.writeFile(outputFile, stringify(report)); 87 | } 88 | -------------------------------------------------------------------------------- /src/fix/suppressions/__test__/remove-unused.test.ts: -------------------------------------------------------------------------------- 1 | import { removeUnusedErrors } from "../remove-unused"; 2 | import { autoSuppressErrors } from "../auto-suppress"; 3 | import { 4 | createOutputRecorder, 5 | getTestFixState, 6 | } from "../../../convert/utils/testing"; 7 | 8 | jest.mock("../../../runner/migration-reporter"); 9 | jest.mock("../../../runner/logger"); 10 | 11 | describe("removeUnused", () => { 12 | beforeEach(() => { 13 | jest.resetAllMocks(); 14 | }); 15 | 16 | it("removes unused error suppressions", async () => { 17 | const [results, recordTestResult] = createOutputRecorder(); 18 | 19 | await removeUnusedErrors( 20 | getTestFixState({ 21 | tsProps: false, 22 | autoSuppressErrors: false, 23 | generateReport: false, 24 | jiraSlug: "", 25 | useIgnore: false, 26 | removeUnused: false, 27 | config: "./src/fix/suppressions/__test__/tsconfig.json", 28 | format: "stdout", 29 | silent: false, 30 | output: "", 31 | autoImport: false, 32 | fixTypeExports: false, 33 | }), 34 | recordTestResult 35 | ); 36 | 37 | expect(results["test-input.ts"]).toMatchSnapshot(); 38 | }); 39 | 40 | it("does not remove if run twice", async () => { 41 | const [results, recordTestResult] = createOutputRecorder(); 42 | const state = getTestFixState({ 43 | tsProps: false, 44 | autoSuppressErrors: false, 45 | generateReport: false, 46 | jiraSlug: "", 47 | useIgnore: false, 48 | removeUnused: false, 49 | config: "./src/fix/suppressions/__test__/tsconfig.json", 50 | format: "stdout", 51 | silent: false, 52 | output: "", 53 | autoImport: false, 54 | fixTypeExports: false, 55 | }); 56 | 57 | await removeUnusedErrors(state, recordTestResult); 58 | await removeUnusedErrors(state, recordTestResult); 59 | 60 | expect(results["test-input.ts"]).toMatchSnapshot(); 61 | }); 62 | 63 | it("works with autoSuppress", async () => { 64 | const [results, recordTestResult] = createOutputRecorder(); 65 | const state = getTestFixState({ 66 | tsProps: false, 67 | autoSuppressErrors: false, 68 | generateReport: false, 69 | jiraSlug: "", 70 | useIgnore: false, 71 | removeUnused: false, 72 | config: "./src/fix/suppressions/__test__/tsconfig.json", 73 | format: "stdout", 74 | silent: false, 75 | output: "", 76 | autoImport: false, 77 | fixTypeExports: false, 78 | }); 79 | 80 | await autoSuppressErrors(state, recordTestResult); 81 | await removeUnusedErrors(state, recordTestResult); 82 | 83 | expect(results["test-input.ts"]).toMatchSnapshot(); 84 | }); 85 | }); 86 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import cluster from "cluster"; 3 | import chalk from "chalk"; 4 | import { 5 | FixCommandCliArgs, 6 | ConvertCommandCliArgs, 7 | SetupCommandCliArgs, 8 | } from "./cli/arguments"; 9 | import { parseCommands } from "./cli/yargs"; 10 | import { logger } from "./runner/logger"; 11 | import { runPrimaryAsync } from "./runner/run-primary-async"; 12 | import { runWorkerAsync } from "./runner/run-worker-async"; 13 | import { runSetupCommand } from "./runner/run-setup-command"; 14 | import { componentPropChecks } from "./fix/component-prop-checks"; 15 | import { autoSuppressErrors } from "./fix/suppressions/auto-suppress"; 16 | import { removeUnusedErrors } from "./fix/suppressions/remove-unused"; 17 | import { generateReport } from "./fix/generate-report"; 18 | import { autoImport } from "./fix/auto-import"; 19 | import { fixTypeExports } from "./fix/fix-type-exports"; 20 | import { getFixState } from "./fix/state"; 21 | 22 | if (cluster.isMaster) { 23 | logger.log(); 24 | logger.log(chalk.underline.bgBlue("Stripe Flow to TypeScript Codemod")); 25 | logger.log(); 26 | } 27 | 28 | /** 29 | * Run the setup command 30 | * Different flags enable different setup scripts 31 | */ 32 | const setup = async (argv: SetupCommandCliArgs) => { 33 | if (argv.silent) { 34 | logger.disable(); 35 | } 36 | await runSetupCommand(argv); 37 | }; 38 | 39 | /** 40 | * Run the Flow -> TS conversion 41 | * Files are converted in batches in parallel processes 42 | */ 43 | const convert = async (argv: ConvertCommandCliArgs) => { 44 | if (argv.silent) { 45 | logger.disable(); 46 | } 47 | 48 | for (const p of argv.path) { 49 | if (!fs.existsSync(p)) { 50 | logger.error(`Provided path ${p} does not exist.`); 51 | process.exit(1); 52 | } 53 | } 54 | 55 | if (cluster.isMaster) { 56 | runPrimaryAsync(argv).catch((error) => { 57 | logger.error(error); 58 | process.exit(1); 59 | }); 60 | } else { 61 | runWorkerAsync(argv).catch((error) => { 62 | logger.error(error); 63 | process.exit(1); 64 | }); 65 | } 66 | }; 67 | 68 | /** 69 | * Fix and report TS errors after conversion 70 | */ 71 | const fix = async (argv: FixCommandCliArgs) => { 72 | if (argv.silent) { 73 | logger.disable(); 74 | } 75 | 76 | const state = getFixState(argv); 77 | 78 | // Read only 79 | if (argv.tsProps) { 80 | await componentPropChecks(state); 81 | } 82 | if (argv.generateReport) { 83 | generateReport(state); 84 | } 85 | 86 | // Modifies files 87 | if (argv.removeUnused) { 88 | await removeUnusedErrors(state); 89 | } 90 | if (argv.autoSuppressErrors) { 91 | await autoSuppressErrors(state); 92 | } 93 | if (argv.autoImport) { 94 | await autoImport(state); 95 | } 96 | if (argv.fixTypeExports) { 97 | await fixTypeExports(state); 98 | } 99 | }; 100 | 101 | parseCommands(convert, fix, setup); 102 | -------------------------------------------------------------------------------- /src/convert/type-annotations.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import traverse from "@babel/traverse"; 3 | import { replaceWith, inheritLocAndComments } from "./utils/common"; 4 | import { migrateType } from "./migrate/type"; 5 | import { migrateTypeParameterDeclaration } from "./migrate/type-parameter"; 6 | import { TransformerInput } from "./transformer"; 7 | import { MetaData } from "./migrate/metadata"; 8 | 9 | /** 10 | * Convert type annotations for variables and parameters 11 | */ 12 | export function transformTypeAnnotations({ 13 | reporter, 14 | state, 15 | file, 16 | }: TransformerInput) { 17 | traverse(file, { 18 | TypeAnnotation(path) { 19 | const metaData: MetaData = { path }; 20 | // Flow automatically makes function parameters that accept `void` not required. 21 | // However, TypeScript requires a parameter even if it is marked as void. So make all 22 | // parameters that accept `void` optional. 23 | if ( 24 | path.parent.type === "Identifier" && 25 | path.parentPath.parent.type !== "VariableDeclarator" 26 | ) { 27 | // `function f(x: ?T)` → `function f(x?: T | null)` 28 | if (path.node.typeAnnotation.type === "NullableTypeAnnotation") { 29 | path.parent.optional = true; 30 | 31 | const nullableType = t.unionTypeAnnotation([ 32 | path.node.typeAnnotation.typeAnnotation, 33 | t.nullLiteralTypeAnnotation(), 34 | ]); 35 | inheritLocAndComments(path.node.typeAnnotation, nullableType); 36 | path.node.typeAnnotation = nullableType; 37 | } 38 | 39 | // `function f(x: T | void)` → `function f(x?: T)` 40 | if ( 41 | path.node.typeAnnotation.type === "UnionTypeAnnotation" && 42 | path.node.typeAnnotation.types.some( 43 | (unionType) => unionType.type === "VoidTypeAnnotation" 44 | ) 45 | ) { 46 | path.parent.optional = true; 47 | path.node.typeAnnotation.types = 48 | path.node.typeAnnotation.types.filter( 49 | (unionType) => unionType.type !== "VoidTypeAnnotation" 50 | ); 51 | } 52 | } 53 | 54 | // Return types might be transformed differently to detect functional components 55 | if ( 56 | path.parentPath.type === "ArrowFunctionExpression" || 57 | path.parentPath.type === "FunctionDeclaration" || 58 | path.parentPath.type === "ClassMethod" 59 | ) { 60 | metaData.returnType = true; 61 | } 62 | 63 | replaceWith( 64 | path, 65 | t.tsTypeAnnotation( 66 | migrateType(reporter, state, path.node.typeAnnotation, metaData) 67 | ), 68 | state.config.filePath, 69 | reporter 70 | ); 71 | }, 72 | 73 | TypeParameterDeclaration(path) { 74 | replaceWith( 75 | path, 76 | migrateTypeParameterDeclaration(reporter, state, path.node), 77 | state.config.filePath, 78 | reporter 79 | ); 80 | }, 81 | }); 82 | } 83 | -------------------------------------------------------------------------------- /src/fix/component-prop-checks/index.ts: -------------------------------------------------------------------------------- 1 | import { ts } from "ts-morph"; 2 | import MigrationReporter from "../../runner/migration-reporter"; 3 | import { stdOutFormatter } from "../../runner/migration-reporter/formatters/std-out-formatter"; 4 | import { buildDiagnosticFilter } from "../build-diagnostic-message-filter"; 5 | import { getImportNamed } from "../get-import-named"; 6 | import { getParentUntil } from "../ts-node-traversal"; 7 | import { jsonFormatter } from "../../runner/migration-reporter/formatters/json-formatter"; 8 | import { logger } from "../../runner/logger"; 9 | import { FixCommandState, getDiagnostics } from "../state"; 10 | 11 | export async function componentPropChecks({ 12 | argv, 13 | migrationReporter, 14 | project, 15 | }: FixCommandState) { 16 | logger.info("Checking Component Props"); 17 | 18 | const diagnostics = getDiagnostics(project); 19 | const TARGETED_ERROR_MESSAGE = "does not exist"; 20 | const diagnosticFilter = buildDiagnosticFilter(TARGETED_ERROR_MESSAGE); 21 | 22 | diagnostics.filter(diagnosticFilter).forEach((error) => { 23 | const sourceFile = error.getSourceFile(); 24 | const myStart = error.getStart(); 25 | 26 | if (!sourceFile || !myStart) { 27 | return; 28 | } 29 | 30 | const descendant = sourceFile.getDescendantAtPos(myStart); 31 | 32 | const attribute = getParentUntil(descendant, ts.isJsxAttribute); 33 | if (!attribute) { 34 | return; 35 | } 36 | const attributeName = String(attribute.compilerNode.name.escapedText); 37 | 38 | const component = getParentUntil(attribute, ts.isJsxOpeningElement); 39 | if (!component) { 40 | return; 41 | } 42 | 43 | const tagName = component.compilerNode.tagName.getFullText(); 44 | 45 | if (tagName.charAt(0) === tagName.charAt(0).toLowerCase()) { 46 | migrationReporter.invalidHTMLProp( 47 | sourceFile.getFilePath(), 48 | error.getLineNumber() ?? 0, 49 | tagName, 50 | attributeName 51 | ); 52 | return; 53 | } 54 | 55 | const theImport = getImportNamed(sourceFile, tagName); 56 | 57 | if (!theImport) { 58 | return; 59 | } 60 | 61 | const importSourceFile = theImport.getModuleSpecifierSourceFile(); 62 | 63 | if (!importSourceFile) { 64 | logger.warn("Import source file not found for import"); 65 | return; 66 | } 67 | 68 | if (importSourceFile.isInNodeModules()) { 69 | migrationReporter.invalidLibraryProp( 70 | sourceFile.getFilePath(), 71 | error.getLineNumber() ?? 0, 72 | importSourceFile.getDirectoryPath(), 73 | tagName, 74 | attributeName 75 | ); 76 | } else { 77 | migrationReporter.invalidAppProp( 78 | sourceFile.getFilePath(), 79 | error.getLineNumber() ?? 0, 80 | importSourceFile?.getFilePath(), 81 | tagName, 82 | attributeName 83 | ); 84 | } 85 | }); 86 | 87 | const formatter = 88 | argv.format === "json" ? jsonFormatter(argv.output) : stdOutFormatter; 89 | await MigrationReporter.logReport( 90 | migrationReporter.generateReport(), 91 | formatter 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/runner/migration-reporter/migration-reporter.test.ts: -------------------------------------------------------------------------------- 1 | import MigrationReporter from "."; 2 | 3 | describe("MigrationReporter", () => { 4 | describe("merge reports", () => { 5 | it("should merge reports as expected", () => { 6 | const firstReport = new MigrationReporter(); 7 | const secondReport = new MigrationReporter(); 8 | 9 | firstReport.objectPropertyWithInternalName("test", { 10 | start: { column: 1, line: 1 }, 11 | end: { column: 2, line: 2 }, 12 | }); 13 | firstReport.objectPropertyWithMinusVariance("test", { 14 | start: { column: 2, line: 2 }, 15 | end: { column: 5, line: 5 }, 16 | }); 17 | secondReport.objectPropertyWithInternalName("test", { 18 | start: { column: 2, line: 3 }, 19 | end: { column: 4, line: 5 }, 20 | }); 21 | secondReport.unsupportedTypeCast("test", { 22 | start: { column: 9, line: 9 }, 23 | end: { column: 9, line: 9 }, 24 | }); 25 | secondReport.unsupportedComponentProp("test", { 26 | start: { column: 11, line: 11 }, 27 | end: { column: 11, line: 11 }, 28 | }); 29 | 30 | const mergedReports = MigrationReporter.mergeReports([ 31 | firstReport.generateReport(), 32 | secondReport.generateReport(), 33 | ]); 34 | 35 | expect(mergedReports.migrationReportItems).toEqual([ 36 | { 37 | filePath: "test", 38 | type: "objectPropertyWithInternalName", 39 | message: 40 | "Encountered an object property using the Flow internal naming format ({ $Key: string }). This pattern is not supported in TypeScript and should be updated.", 41 | severity: "warn", 42 | start: { column: 1, line: 1 }, 43 | end: { column: 2, line: 2 }, 44 | }, 45 | { 46 | filePath: "test", 47 | type: "objectPropertyWithMinusVariance", 48 | message: 49 | "Encountered an object property using Flow type variance ({ key: -string }) that cannot be cleanly converted.", 50 | severity: "warn", 51 | start: { column: 2, line: 2 }, 52 | end: { column: 5, line: 5 }, 53 | }, 54 | { 55 | filePath: "test", 56 | type: "objectPropertyWithInternalName", 57 | message: 58 | "Encountered an object property using the Flow internal naming format ({ $Key: string }). This pattern is not supported in TypeScript and should be updated.", 59 | severity: "warn", 60 | start: { column: 2, line: 3 }, 61 | end: { column: 4, line: 5 }, 62 | }, 63 | { 64 | filePath: "test", 65 | type: "unsupportedTypeCast", 66 | message: "Encountered an unsupported type cast", 67 | 68 | severity: "info", 69 | start: { column: 9, line: 9 }, 70 | end: { column: 9, line: 9 }, 71 | }, 72 | { 73 | filePath: "test", 74 | type: "unsupportedComponentProp", 75 | message: "Unsupported prop supplied to this component.", 76 | severity: "warn", 77 | start: { column: 11, line: 11 }, 78 | end: { column: 11, line: 11 }, 79 | }, 80 | ]); 81 | }); 82 | }); 83 | }); 84 | -------------------------------------------------------------------------------- /src/test/test-files/flow_typescript_differences.js: -------------------------------------------------------------------------------- 1 | // @flow 2 | // Examples taken from https://github.com/niieani/typescript-vs-flowtype 3 | 4 | // bounded polymorphism 5 | function fooGood(obj: T): T { 6 | console.log(Math.abs(obj.x)); 7 | return obj; 8 | } 9 | 10 | // Maybe and nullable 11 | let a: ?string; 12 | 13 | // Optional params 14 | function fnOptional(x?: number) {} 15 | 16 | // Casting 17 | (1 + 1: number); 18 | 19 | // Exact/partial types 20 | type ExactUser = {|name: string, age: number|}; 21 | type User = {name: string, age: number}; 22 | type OptionalUser = $Shape; 23 | 24 | // Mixed 25 | function stringifyNum(num: number) { 26 | // Do stuff 27 | } 28 | 29 | function stringify(value: mixed) { 30 | if (typeof value === 'string') { 31 | return '' + value; // Works! 32 | } 33 | if (typeof value === 'number') { 34 | return stringifyNum(value); // Works! 35 | } 36 | return ''; 37 | } 38 | 39 | // Class types 40 | class Test {} 41 | type TestType = typeof Test; 42 | 43 | const instance = new Test(); 44 | type TestTypeFromInstance = Class; 45 | 46 | // Keys 47 | var props = { 48 | foo: 1, 49 | bar: 'two', 50 | baz: 'three', 51 | }; 52 | 53 | function fnUnion(x: number | void) {} 54 | 55 | // Type inference 56 | function addOne(a) { 57 | return a + 1; 58 | } 59 | const addOneArrow = (a) => a + 1; 60 | 61 | const onePlus = [1, 2, 3].map(function addOne(a) { 62 | return a + 1; 63 | }); 64 | 65 | const onePlusArrow = [1, 2, 3].map((a) => a + 1); 66 | 67 | // Lookup types 68 | type A = { 69 | thing: string, 70 | }; 71 | 72 | // when the property is a string constant use $PropertyType (i.e. you know it when typing) 73 | type lookedUpThing = $PropertyType; 74 | 75 | // when you want the property to be dynamic use $ElementType (since Flow 0.49) 76 | // Fix me 77 | // function getProperty(obj: T, key: Key): $ElementType { 78 | // return obj[key]; 79 | // }+ 80 | 81 | // Type narrowing 82 | // Fix me 83 | // function isNil(value: mixed): boolean %checks { 84 | // return value == null; 85 | // } 86 | 87 | // const thing = null; 88 | 89 | // if (!isNil(thing)) { 90 | // const another = thing.something; 91 | // } 92 | 93 | // Call 94 | type Fn1 = (T) => T; 95 | type E = $Call; 96 | 97 | declare var e: E; // E is number 98 | (42: E); // OK 99 | 100 | // Mapped types 101 | type InputType = {hello: string}; 102 | 103 | type MappedType = $ObjMap number>; 104 | 105 | type FormFieldDef = {| 106 | label: string, 107 | description?: string, 108 | default: T | (() => T), 109 | |}; 110 | 111 | type ExtractShape = (T) => FormFieldDef; 112 | 113 | type FormFields = $ObjMap; 114 | 115 | // // Overloading 116 | // Fix me 117 | // declare function add(x: string, y: string): string; 118 | // declare function add(x: number, y: number): number; 119 | 120 | declare class Adder { 121 | add(x: string, y: string): string; 122 | add(x: number, y: number): number; 123 | } 124 | 125 | // Readonly 126 | type B = { 127 | +b: string, 128 | }; 129 | 130 | let b: B = {b: 'something'}; 131 | 132 | // Difference 133 | type C = $Diff<{a: string, b: number}, {a: string}>; 134 | 135 | // Rest 136 | type Props = {name: string, age: number}; 137 | 138 | const propsRest: Props = {name: 'Jon', age: 42}; 139 | const {age, ...otherProps} = propsRest; 140 | (otherProps: $Rest); 141 | 142 | // Same 143 | type F = { 144 | (): string, 145 | }; 146 | const f: F = () => 'hello'; 147 | const hello: string = f(); 148 | -------------------------------------------------------------------------------- /src/cli/arguments.ts: -------------------------------------------------------------------------------- 1 | export const migrationReportFormats = ["json", "csv", "stdout"] as const; 2 | export type MigrationReportFormat = typeof migrationReportFormats[number]; 3 | 4 | export interface SharedCommandCliArgs { 5 | // The format the migration reporter should use 6 | format: MigrationReportFormat; 7 | // Where to save the reporter (if using JSON formatting) 8 | output: string; 9 | // Disable logging to stdout 10 | silent: boolean; 11 | } 12 | 13 | export interface SetupCommandCliArgs extends SharedCommandCliArgs { 14 | // Whether or not to install typescript 15 | installTS: boolean; 16 | // Whether or not to setup a TSConfig 17 | setupTSConfig: boolean; 18 | // Whether or not we should recommend type definitions to install 19 | recommendTypeDefinitions: boolean; 20 | // The source path to run against 21 | path: string; 22 | } 23 | 24 | export interface ConvertCommandCliArgs extends SharedCommandCliArgs { 25 | // The source path to run against 26 | path: Array; 27 | // Should a watermark be added to output typescript files 28 | watermark: boolean; 29 | // The topline tag used in the watermark. Only used if watermark is enabled 30 | tag: string; 31 | // The message block comment used under the tag. Only used if watermark is enabled. 32 | message: string; 33 | // delete old flow files. 34 | delete: boolean; 35 | // By default, typescriptify will run against the code and output a report of the results. Enabling write will actually write ts/tsx files 36 | write: boolean; 37 | // Change the output dir of the codemod: 38 | target: string; 39 | // Flow has a deprecated 'Object' type that translates to any. This flag will take the semantic meaning of any object when set 40 | useStrictAnyObjectType: boolean; 41 | // Flow has a deprecated 'Function' type that translates to any. This flag will take the semantic meaning of any function when set 42 | useStrictAnyFunctionType: boolean; 43 | // Remove `.js` and `.jsx` file extensions which will change 44 | dropImportExtensions: boolean; 45 | // Add support for spread props on React Components 46 | handleSpreadReactProps: boolean; 47 | // Don't replace all $ with . 48 | keepPrivateTypes: boolean; 49 | // Array of directories or files to ignore 50 | ignore: Array; 51 | // Force all file extensions to end in tsx 52 | forceTSX: boolean; 53 | // Skip renaming @noflow annotated files 54 | skipNoFlow: boolean; 55 | // Disable Flow inference for performance 56 | disableFlow: boolean; 57 | } 58 | 59 | export interface FixCommandCliArgs extends SharedCommandCliArgs { 60 | // Check for cases where props were passed to a React component that were not defined' 61 | tsProps: boolean; 62 | // Auto suppress any TypeScript errors with a ts-expect-error comment 63 | autoSuppressErrors: boolean; 64 | // Auto import missing types using typescript 65 | autoImport: boolean; 66 | // Experimental: Fix exported types to use type-only exports. 67 | fixTypeExports: boolean; 68 | // Generate a report of TypeScript errors. 69 | generateReport: boolean; 70 | // This slug will be appended to the ts-expect-error 71 | jiraSlug: string; 72 | // Use ts-ignore instead of ts-expect-error 73 | useIgnore: boolean; 74 | // Remove unused ts-expect-error 75 | removeUnused: boolean; 76 | // Path to tsconfig to use for report or error suppression 77 | config: string; 78 | } 79 | 80 | export const DEFAULT_WATERMARK_TAG = "@typescriptify"; 81 | export const DEFAULT_WATERMARK_MESSAGE = ` 82 | THIS FILE IS AUTOMATICALLY GENERATED. Do not edit this file. 83 | If you want to manually write flow types for this file, 84 | remove the @typescriptify annotation and this comment block. 85 | `; 86 | -------------------------------------------------------------------------------- /src/convert/flow/annotate-params.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import { NodePath } from "@babel/traverse"; 3 | import MigrationReporter from "../../runner/migration-reporter"; 4 | import { State } from "../../runner/state"; 5 | import { flowTypeAtPos } from "./type-at-pos"; 6 | import { migrateType } from "../migrate/type"; 7 | 8 | /** 9 | * There are some cases where it's fine if variables or parameters don't have a type 10 | * These are often cases where the parent of the node provides a type 11 | * In these cases we want to avoid annotating a type from Flow, which is often different 12 | * and just ends up causing more errors. Flow is also slow to query so this helps 13 | * reduce calls. 14 | */ 15 | function parentProvidesImplicitType(path: NodePath): boolean { 16 | const parent = path.parentPath; 17 | switch (parent?.node.type) { 18 | case "VariableDeclarator": 19 | return ( 20 | t.isIdentifier(parent.node.id) && parent.node.id.typeAnnotation != null 21 | ); 22 | case "CallExpression": 23 | return ( 24 | t.isIdentifier(parent.node.callee) && 25 | parent.node.callee.typeAnnotation != null 26 | ); 27 | case "AssignmentExpression": 28 | return ( 29 | t.isIdentifier(parent.node.left) && 30 | parent.node.left.typeAnnotation != null 31 | ); 32 | case "TSAsExpression": 33 | case "TypeCastExpression": 34 | case "ObjectProperty": 35 | case "JSXExpressionContainer": 36 | return true; 37 | case "ClassProperty": 38 | case "ClassPrivateProperty": 39 | return parent.node.typeAnnotation != null; 40 | case "ArrowFunctionExpression": 41 | return parent.node.returnType != null; 42 | 43 | // Currently we don’t support type annotations in object properties 44 | // case 'ObjectProperty': 45 | // case 'ObjectExpression': 46 | // return parentProvidesImplicitType(parent); 47 | } 48 | 49 | return false; 50 | } 51 | 52 | /** 53 | * Adds a type annotation to all unannotated function parameters. 54 | */ 55 | export function annotateParamsWithFlowTypeAtPos( 56 | reporter: MigrationReporter, 57 | state: State, 58 | params: t.ClassMethod["params"], 59 | path: NodePath, 60 | isInsideCreateReactClass?: boolean 61 | ): Promise { 62 | if (!isInsideCreateReactClass && parentProvidesImplicitType(path)) { 63 | return Promise.resolve(); 64 | } 65 | 66 | if (state.config.disableFlow) { 67 | for (const param of params) { 68 | if (param.type === "Identifier" && !param.typeAnnotation) { 69 | param.typeAnnotation = t.tsTypeAnnotation(t.tsUnknownKeyword()); 70 | reporter.disableFlowCheck(state.config.filePath, param.loc!); 71 | } 72 | } 73 | 74 | return Promise.resolve(); 75 | } 76 | 77 | const awaitPromises: Array> = []; 78 | 79 | for (const param of params) { 80 | if (param.type === "Identifier" && !param.typeAnnotation) { 81 | awaitPromises.push( 82 | (async () => { 83 | // Get the type Flow is inferring for this unannotated function parameter. 84 | const flowType = await flowTypeAtPos(state, param.loc!, reporter); 85 | if (flowType === null) return; 86 | 87 | // If Flow inferred `empty` then that means there were no calls to the 88 | // function and therefore no “lower type bounds” for the parameter. This 89 | // means you can do anything with the type effectively making it any. So 90 | // treat it as such. 91 | const tsType = 92 | flowType.type === "EmptyTypeAnnotation" 93 | ? t.tsAnyKeyword() 94 | : migrateType(reporter, state, flowType); 95 | 96 | // Add the type annotation! Yaay. 97 | param.typeAnnotation = t.tsTypeAnnotation(tsType); 98 | })() 99 | ); 100 | } 101 | } 102 | 103 | return Promise.all(awaitPromises); 104 | } 105 | -------------------------------------------------------------------------------- /src/test/regression/__snapshots__/regression.test.ts.snap: -------------------------------------------------------------------------------- 1 | // Jest Snapshot v1, https://goo.gl/fbAQLP 2 | 3 | exports[`Regression tests flow_typescript_differences 1`] = ` 4 | "import {Flow} from 'flow-to-typescript-codemod'; 5 | // Examples taken from https://github.com/niieani/typescript-vs-flowtype 6 | 7 | // bounded polymorphism 8 | function fooGood(obj: T): T { 11 | console.log(Math.abs(obj.x)); 12 | return obj; 13 | } 14 | 15 | // Maybe and nullable 16 | let a: string | null | undefined; 17 | 18 | // Optional params 19 | function fnOptional(x?: number) {} 20 | 21 | // Casting 22 | (1 + 1 as number); 23 | 24 | // Exact/partial types 25 | type ExactUser = { 26 | name: string, 27 | age: number 28 | }; 29 | type User = { 30 | name: string, 31 | age: number 32 | }; 33 | type OptionalUser = Partial; 34 | 35 | // Mixed 36 | function stringifyNum(num: number) { 37 | // Do stuff 38 | } 39 | 40 | function stringify(value: unknown) { 41 | if (typeof value === 'string') { 42 | return '' + value; // Works! 43 | } 44 | if (typeof value === 'number') { 45 | return stringifyNum(value); // Works! 46 | } 47 | return ''; 48 | } 49 | 50 | // Class types 51 | class Test {} 52 | type TestType = typeof Test; 53 | 54 | const instance = new Test(); 55 | type TestTypeFromInstance = Flow.Class; 56 | 57 | // Keys 58 | var props = { 59 | foo: 1, 60 | bar: 'two', 61 | baz: 'three', 62 | }; 63 | 64 | function fnUnion(x?: number) {} 65 | 66 | // Type inference 67 | function addOne(a: any) { 68 | return a + 1; 69 | } 70 | const addOneArrow = (a: any) => a + 1; 71 | 72 | const onePlus = [1, 2, 3].map(function addOne(a) { 73 | return a + 1; 74 | }); 75 | 76 | const onePlusArrow = [1, 2, 3].map((a) => a + 1); 77 | 78 | // Lookup types 79 | type A = { 80 | thing: string 81 | }; 82 | 83 | // when the property is a string constant use $PropertyType (i.e. you know it when typing) 84 | type lookedUpThing = A['thing']; 85 | 86 | // when you want the property to be dynamic use $ElementType (since Flow 0.49) 87 | // Fix me 88 | // function getProperty(obj: T, key: Key): $ElementType { 89 | // return obj[key]; 90 | // }+ 91 | 92 | // Type narrowing 93 | // Fix me 94 | // function isNil(value: mixed): boolean %checks { 95 | // return value == null; 96 | // } 97 | 98 | // const thing = null; 99 | 100 | // if (!isNil(thing)) { 101 | // const another = thing.something; 102 | // } 103 | 104 | // Call 105 | type Fn1 = (arg1: T) => T; 106 | type E = ReturnType; 107 | 108 | declare var e: E; // E is number 109 | (42 as E); // OK 110 | 111 | // Mapped types 112 | type InputType = { 113 | hello: string 114 | }; 115 | 116 | type MappedType = Flow.ObjMap number>; 117 | 118 | type FormFieldDef = { 119 | label: string, 120 | description?: string, 121 | default: T | (() => T) 122 | }; 123 | 124 | type ExtractShape = (arg1: T) => FormFieldDef; 125 | 126 | type FormFields = Flow.ObjMap; 127 | 128 | // // Overloading 129 | // Fix me 130 | // declare function add(x: string, y: string): string; 131 | // declare function add(x: number, y: number): number; 132 | 133 | declare class Adder { 134 | add(x: string, y: string): string; 135 | add(x: number, y: number): number; 136 | } 137 | 138 | // Readonly 139 | type B = { 140 | readonly b: string 141 | }; 142 | 143 | let b: B = {b: 'something'}; 144 | 145 | // Difference 146 | type C = Flow.Diff<{ 147 | a: string, 148 | b: number 149 | }, { 150 | a: string 151 | }>; 152 | 153 | // Rest 154 | type Props = { 155 | name: string, 156 | age: number 157 | }; 158 | 159 | const propsRest: Props = {name: 'Jon', age: 42}; 160 | const {age, ...otherProps} = propsRest; 161 | (otherProps as Partial>); 164 | 165 | // Same 166 | type F = { 167 | (): string 168 | }; 169 | const f: F = () => 'hello'; 170 | const hello: string = f(); 171 | " 172 | `; 173 | -------------------------------------------------------------------------------- /src/convert/jsx-spread/components-with-spreads.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import { NodePath } from "@babel/traverse"; 3 | import { getComponentType } from "./get-component-type"; 4 | 5 | export function componentsWithSpreads( 6 | path: NodePath, 7 | propArgumentName: string, 8 | ignoredNodes: string[] = [] 9 | ) { 10 | const componentsWithSpreads: t.TSType[] = []; 11 | const restPatterns: Record = {}; 12 | const ignoredAttributes: string[] = []; 13 | path.traverse( 14 | { 15 | JSXOpeningElement(openingElementPath) { 16 | if (this.restName === "") { 17 | return; 18 | } 19 | 20 | const { node } = openingElementPath; 21 | 22 | const elementState = { 23 | restName: this.restName, 24 | isSpread: false, 25 | omittedAttributes: [] as string[], 26 | }; 27 | 28 | if (!t.isJSXIdentifier(node.name)) { 29 | return; 30 | } 31 | 32 | if (ignoredNodes.indexOf(node.name.name) !== -1) { 33 | return; 34 | } 35 | 36 | ignoredNodes.push(node.name.name); 37 | 38 | openingElementPath.traverse( 39 | { 40 | JSXSpreadAttribute({ node }) { 41 | if ( 42 | t.isIdentifier(node.argument) && 43 | node.argument.name === this.restName 44 | ) { 45 | this.isSpread = true; 46 | } 47 | }, 48 | JSXAttribute({ node }) { 49 | if (t.isJSXIdentifier(node.name)) { 50 | this.omittedAttributes.push(node.name.name); 51 | } 52 | }, 53 | }, 54 | elementState 55 | ); 56 | if (elementState.isSpread && t.isJSXIdentifier(node.name)) { 57 | const namePropsType = getComponentType(node.name.name); 58 | 59 | let updatedComponentProps; 60 | if (elementState.omittedAttributes.length > 0) { 61 | updatedComponentProps = t.tsTypeReference( 62 | t.identifier("Omit"), 63 | t.tsTypeParameterInstantiation([ 64 | namePropsType, 65 | t.tsUnionType( 66 | elementState.omittedAttributes.map((attr) => 67 | t.tsLiteralType(t.stringLiteral(attr)) 68 | ) 69 | ), 70 | ]) 71 | ); 72 | } else { 73 | updatedComponentProps = namePropsType; 74 | } 75 | 76 | this.componentsWithSpreads.push(updatedComponentProps); 77 | } 78 | }, 79 | 80 | VariableDeclarator({ node }) { 81 | const { id, init } = node; 82 | 83 | if (t.isIdentifier(id)) { 84 | ignoredNodes.push(id.name); 85 | } 86 | 87 | if (!t.isObjectPattern(id)) { 88 | return; 89 | } 90 | 91 | const restPattern = id.properties.find((node) => t.isRestElement(node)); 92 | 93 | if (!restPattern || !t.isRestElement(restPattern)) { 94 | return; 95 | } 96 | 97 | let initName = null; 98 | if ( 99 | init?.type === "MemberExpression" && 100 | init.object.type === "ThisExpression" && 101 | init.property.type === "Identifier" && 102 | init.property.name === "props" 103 | ) { 104 | initName = "this.props"; 105 | } else if (init?.type === "Identifier") { 106 | initName = init.name; 107 | } 108 | 109 | if ( 110 | initName === this.propArgumentName && 111 | t.isIdentifier(restPattern.argument) 112 | ) { 113 | this.restName = restPattern.argument.name; 114 | } 115 | }, 116 | }, 117 | { 118 | componentsWithSpreads, 119 | restPatterns, 120 | propArgumentName, 121 | restName: "", 122 | ignoredAttributes, 123 | ignoredNodes, 124 | } 125 | ); 126 | 127 | return componentsWithSpreads; 128 | } 129 | -------------------------------------------------------------------------------- /src/convert/remove-flow-comments.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import { transform } from "./utils/testing"; 3 | 4 | const standardProgram = `const myFunction = (i: number) => i + i;`; 5 | 6 | describe("remove-flow-pragmas", () => { 7 | const expected = dedent`${standardProgram}`; 8 | it("should remove standard inline flow pragmas", async () => { 9 | const src = dedent` 10 | // @flow 11 | 12 | ${standardProgram} 13 | `; 14 | 15 | expect(await transform(src)).toEqual(expected); 16 | }); 17 | 18 | it("should remove standard block flow pragmas", async () => { 19 | const src = dedent` 20 | /* @flow */ 21 | 22 | ${standardProgram} 23 | `; 24 | 25 | expect(await transform(src)).toEqual(expected); 26 | }); 27 | 28 | it("should remove the comment even if it has extra comment marks", async () => { 29 | const src = dedent` 30 | // ///// / / / / / /// // // /// ////////// /// // /////// @flow 31 | 32 | ${standardProgram} 33 | `; 34 | 35 | expect(await transform(src)).toEqual(expected); 36 | }); 37 | 38 | it("should replace noflow with the ts-nocheck", async () => { 39 | const src = dedent` 40 | // @noflow 41 | 42 | ${standardProgram} 43 | `; 44 | 45 | const expected = dedent` 46 | // @ts-nocheck 47 | 48 | ${standardProgram} 49 | `; 50 | 51 | expect(await transform(src)).toEqual(expected); 52 | }); 53 | 54 | it("should replace noflow with ts-nocheck in a block comment", async () => { 55 | const src = dedent` 56 | /* @noflow */ 57 | 58 | ${standardProgram} 59 | `; 60 | 61 | const expected = dedent` 62 | /* @ts-nocheck */ 63 | 64 | ${standardProgram} 65 | `; 66 | 67 | expect(await transform(src)).toEqual(expected); 68 | }); 69 | 70 | it("should replace noflow with ts-nocheck event if it has extra comment marks", async () => { 71 | const src = dedent` 72 | // ///// / / / / / /// // // /// ////////// /// // /////// @noflow 73 | 74 | ${standardProgram} 75 | `; 76 | 77 | const expected = dedent` 78 | // ///// / / / / / /// // // /// ////////// /// // /////// @ts-nocheck 79 | 80 | ${standardProgram} 81 | `; 82 | 83 | expect(await transform(src)).toEqual(expected); 84 | }); 85 | 86 | it("should remove suppressions", async () => { 87 | const src = dedent` 88 | // $FlowFixMe 89 | // $FlowIssue 90 | // $FlowExpectedError 91 | // $FlowIgnore 92 | 93 | ${standardProgram} 94 | `; 95 | 96 | const expected = dedent` 97 | 98 | ${standardProgram} 99 | `; 100 | 101 | expect(await transform(src)).toEqual(expected); 102 | }); 103 | 104 | it("should leave non suppressions", async () => { 105 | const src = dedent` 106 | // $FlowNotSuppression 107 | // normal comment 108 | 109 | ${standardProgram} 110 | `; 111 | 112 | const expected = dedent` 113 | // $FlowNotSuppression 114 | // normal comment 115 | 116 | ${standardProgram} 117 | `; 118 | 119 | expect(await transform(src)).toEqual(expected); 120 | }); 121 | 122 | it("should remove suppressions inside code blocks", async () => { 123 | const src = dedent` 124 | // $FlowIgnore 125 | const myFunction = (i: number) => { 126 | // $FlowFixMe 127 | return i + i; 128 | }; 129 | if (foo) { 130 | // $FlowIssue 131 | console.log('bar'); 132 | } 133 | `; 134 | 135 | const expected = dedent` 136 | const myFunction = (i: number) => { 137 | return i + i; 138 | }; 139 | if (foo) { 140 | console.log('bar'); 141 | } 142 | `; 143 | 144 | expect(await transform(src)).toEqual(expected); 145 | }); 146 | 147 | it("should remove multiple suppressions", async () => { 148 | const src = dedent` 149 | const handler = () => { 150 | // $FlowIgnore - test 151 | const validWindowTarget = true; 152 | 153 | // $FlowIgnore - test 154 | const unknownWindowTarget = false; 155 | 156 | console.log(validWindowTarget.name); 157 | }; 158 | `; 159 | 160 | const expected = dedent` 161 | const handler = () => { 162 | const validWindowTarget = true; 163 | 164 | const unknownWindowTarget = false; 165 | 166 | console.log(validWindowTarget.name); 167 | }; 168 | `; 169 | 170 | expect(await transform(src)).toEqual(expected); 171 | }); 172 | }); 173 | -------------------------------------------------------------------------------- /src/fix/suppressions/remove-unused.ts: -------------------------------------------------------------------------------- 1 | import { SourceFile, ts } from "ts-morph"; 2 | import { FixCommandState, getDiagnostics } from "../state"; 3 | import { logger } from "../../runner/logger"; 4 | import { CommentToMake, CommentType } from "./shared"; 5 | 6 | interface Metrics { 7 | removed: number; 8 | } 9 | 10 | // See https://github.com/microsoft/TypeScript/blob/e9453f411a3599d811e043390a167c40866e9630/src/compiler/diagnosticMessages.json 11 | const TYPESCRIPT_UNUSED_EXPECT_ERROR_CODE = 2578; 12 | 13 | /** 14 | * Remove suppression comments for unused expect errors in the file 15 | */ 16 | function removeUnusedInFile( 17 | metrics: Metrics, 18 | positions: Record, 19 | sourceFile: SourceFile 20 | ) { 21 | let addedLength = 0; 22 | for (const { commentType, diagnostics } of Object.values(positions)) { 23 | const unusedExpectError = diagnostics.find( 24 | (error) => error.getCode() === TYPESCRIPT_UNUSED_EXPECT_ERROR_CODE 25 | ); 26 | if (unusedExpectError !== undefined) { 27 | const start = unusedExpectError.getStart(); 28 | const length = unusedExpectError.getLength(); 29 | if (start !== undefined && length !== undefined) { 30 | const insertPos = start + addedLength; 31 | try { 32 | if (commentType === CommentType.Jsx) { 33 | // JSX comments have curly braces on either side that we want to delete 34 | sourceFile.replaceText([insertPos - 2, insertPos + length + 2], ""); 35 | addedLength -= length + 4; 36 | } else { 37 | sourceFile.replaceText([insertPos, insertPos + length], ""); 38 | addedLength -= length; 39 | } 40 | metrics.removed += 1; 41 | } catch (error) { 42 | logger.error(`Error when trying to remove suppressions at pos:${ 43 | start + addedLength 44 | } in ${sourceFile.getFilePath()}. 45 | This often indicates syntax errors. This file will be skipped.`); 46 | 47 | // Break out of removing comments for this file 48 | return addedLength; 49 | } 50 | } 51 | } 52 | } 53 | } 54 | 55 | type FileWriter = (file: SourceFile) => void; 56 | const defaultWriter = (file: SourceFile) => file.saveSync(); 57 | 58 | export async function removeUnusedErrors( 59 | { project }: FixCommandState, 60 | writeFile: FileWriter = defaultWriter 61 | ) { 62 | const diagnosticsByFile: Map< 63 | SourceFile, 64 | Record 65 | > = new Map(); 66 | logger.info("Removing unused suppressions.."); 67 | 68 | const diagnostics = getDiagnostics(project); 69 | diagnostics.forEach((error) => { 70 | const sourceFile = error.getSourceFile(); 71 | 72 | if (!sourceFile) { 73 | return; 74 | } 75 | 76 | const errorStart = error.getStart(); 77 | if (!errorStart) { 78 | return; 79 | } 80 | 81 | const node = sourceFile.getDescendantAtPos(errorStart); 82 | const errorStartLine = node?.getStartLinePos(true); 83 | if (errorStartLine == null) { 84 | return; 85 | } 86 | 87 | // Find out which comment we should make 88 | const errorLine = sourceFile.getDescendantAtPos(errorStartLine); 89 | const commentsForFile = diagnosticsByFile.get(sourceFile) ?? {}; 90 | const commentsForLine = commentsForFile[errorStartLine] ?? { 91 | position: errorStartLine, 92 | commentType: [ 93 | ts.SyntaxKind.JsxElement, 94 | ts.SyntaxKind.JsxFragment, 95 | ts.SyntaxKind.JsxExpression, 96 | ts.SyntaxKind.JsxClosingElement, 97 | ].some((elementType) => errorLine?.getParentIfKind(elementType) != null) 98 | ? CommentType.Jsx 99 | : CommentType.Standard, 100 | diagnostics: [], 101 | }; 102 | 103 | commentsForLine.diagnostics.push(error); 104 | commentsForFile[errorStartLine] = commentsForLine; 105 | diagnosticsByFile.set(sourceFile, commentsForFile); 106 | }); 107 | 108 | const metrics: Metrics = { 109 | removed: 0, 110 | }; 111 | 112 | for (const [sourceFile, positions] of diagnosticsByFile) { 113 | removeUnusedInFile(metrics, positions, sourceFile); 114 | 115 | try { 116 | writeFile(sourceFile); 117 | } catch (e) { 118 | logger.warn( 119 | `Error when saving suppressed source file. Ensure that node_modules is not being type checked by your TSConfig. Error: ${e}.` 120 | ); 121 | } 122 | } 123 | 124 | logger.complete( 125 | `Removed ${metrics.removed} errors across ${diagnosticsByFile.size} files.` 126 | ); 127 | } 128 | -------------------------------------------------------------------------------- /src/convert/flow/type-at-pos.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import { transform } from "../utils/testing"; 3 | import { executeFlowTypeAtPos } from "./execute-type-at-pos"; 4 | 5 | jest.mock("./execute-type-at-pos.ts"); 6 | const mockedExecuteFlowTypeAtPos = < 7 | jest.MockedFunction 8 | >executeFlowTypeAtPos; 9 | 10 | describe("type at position", () => { 11 | afterEach(mockedExecuteFlowTypeAtPos.mockReset); 12 | 13 | it("annotates primitive types", async () => { 14 | const src = `function fn(a, b) {return a + b};`; 15 | const expected = `function fn(a: string, b: string) {return a + b};`; 16 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "string"}'); 17 | expect(await transform(src)).toBe(expected); 18 | }); 19 | 20 | it("annotates union types", async () => { 21 | const src = `function fn(a) {return a};`; 22 | const expected = `function fn(a: string | number) {return a};`; 23 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "string | number"}'); 24 | expect(await transform(src)).toBe(expected); 25 | }); 26 | 27 | it("does not annotate empty", async () => { 28 | const src = `function fn(a) {return a};`; 29 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": ""}'); 30 | expect(await transform(src)).toBe(src); 31 | }); 32 | 33 | it("does not annotate unknown", async () => { 34 | const src = `function fn(a) {return a};`; 35 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "unknown"}'); 36 | expect(await transform(src)).toBe(src); 37 | }); 38 | 39 | it("does not annotate implicit unknown", async () => { 40 | const src = `function fn(a) {return a};`; 41 | mockedExecuteFlowTypeAtPos.mockResolvedValue( 42 | '{"type": "unknown(implicit)"}' 43 | ); 44 | expect(await transform(src)).toBe(src); 45 | }); 46 | 47 | it("does not annotate explicit unknown", async () => { 48 | const src = `function fn(a) {return a};`; 49 | mockedExecuteFlowTypeAtPos.mockResolvedValue( 50 | '{"type": "unknown(explicit)"}' 51 | ); 52 | expect(await transform(src)).toBe(src); 53 | }); 54 | 55 | it("does not annotate any", async () => { 56 | const src = `function fn(a) {return a};`; 57 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "any"}'); 58 | expect(await transform(src)).toBe(src); 59 | }); 60 | 61 | it("does not annotate implicit any", async () => { 62 | const src = `function fn(a) {return a};`; 63 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "any(implicit)"}'); 64 | expect(await transform(src)).toBe(src); 65 | }); 66 | 67 | it("annotates explicit any", async () => { 68 | const src = `function fn(a) {return a};`; 69 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "any(explicit)"}'); 70 | const expected = `function fn(a: any) {return a};`; 71 | expect(await transform(src)).toBe(expected); 72 | }); 73 | 74 | it("does not annotate unions with any", async () => { 75 | const src = `function fn(a) {return a};`; 76 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "string | any"}'); 77 | expect(await transform(src)).toBe(src); 78 | }); 79 | 80 | it("converts utility types", async () => { 81 | const src = `function fn(a) {return a};`; 82 | const expected = `function fn(a: Partial) {return a};`; 83 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "$Shape"}'); 84 | expect(await transform(src)).toBe(expected); 85 | }); 86 | 87 | it("pre-processes private types", async () => { 88 | const src = `function fn(a) {return a};`; 89 | const expected = `function fn(a: React.ReactNode) {return a};`; 90 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "React$Node"}'); 91 | expect(await transform(src)).toBe(expected); 92 | }); 93 | 94 | it("handles empty", async () => { 95 | const src = dedent`const PageWrapper = (page) => { 96 | return Wrap(page, PricePage); 97 | };`; 98 | const expected = dedent`const PageWrapper = (page: any) => { 99 | return Wrap(page, PricePage); 100 | };`; 101 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "empty"}'); 102 | expect(await transform(src)).toBe(expected); 103 | }); 104 | 105 | it("handles flow errors", async () => { 106 | const src = dedent`function fn(a) { 107 | type Foo = $Shape; 108 | return a; 109 | };`; 110 | const expected = dedent`function fn(a) { 111 | type Foo = Partial; 112 | return a; 113 | };`; 114 | mockedExecuteFlowTypeAtPos.mockRejectedValue("Command failed"); 115 | expect(await transform(src)).toBe(expected); 116 | }); 117 | }); 118 | -------------------------------------------------------------------------------- /src/fix/suppressions/auto-suppress.ts: -------------------------------------------------------------------------------- 1 | import { Diagnostic, Project, SourceFile, ts } from "ts-morph"; 2 | import { FixCommandState, getDiagnostics } from "../state"; 3 | import { FixCommandCliArgs } from "../../cli/arguments"; 4 | import { logger } from "../../runner/logger"; 5 | import { isDiagnosticSuppressible } from "../insuppressible-errors"; 6 | import { diagnosticToDescription } from "./diagnostic-to-description"; 7 | import { CommentToMake, CommentType } from "./shared"; 8 | 9 | interface Metrics { 10 | suppressed: number; 11 | } 12 | 13 | function makeComment({ 14 | commentType, 15 | jiraSlug, 16 | diagnostics, 17 | annotation = "@ts-expect-error", 18 | }: { 19 | commentType: CommentType; 20 | jiraSlug: string; 21 | diagnostics: Diagnostic[]; 22 | annotation?: string; 23 | }): string { 24 | // If jira slug was not specified, don't include anything 25 | const fullJiraSlug = jiraSlug === "" ? "" : ` [${jiraSlug}]`; 26 | 27 | const commentText = `${annotation}${fullJiraSlug} - ${diagnostics 28 | .map((diagnostic) => diagnosticToDescription(diagnostic)) 29 | .join(" | ")}`; 30 | 31 | if (commentType === CommentType.Jsx) { 32 | return `{ /* ${commentText} */}\n`; 33 | } 34 | return `// ${commentText}\n`; 35 | } 36 | 37 | /** 38 | * Add suppression comments for errors in the file 39 | */ 40 | function addSuppressionsInFile( 41 | metrics: Metrics, 42 | positions: Record, 43 | sourceFile: SourceFile, 44 | project: Project, 45 | { jiraSlug, useIgnore }: FixCommandCliArgs 46 | ) { 47 | let addedLength = 0; 48 | for (const { position, commentType, diagnostics } of Object.values( 49 | positions 50 | )) { 51 | const isInsuppressible = diagnostics.find((error) => { 52 | const isInsuppressible = !isDiagnosticSuppressible(error); 53 | if (isInsuppressible) { 54 | logger.error( 55 | `Found an insuppressible error. Please fix manually: 56 | ${project.formatDiagnosticsWithColorAndContext([error])}` 57 | ); 58 | } 59 | return isInsuppressible; 60 | }); 61 | if (!isInsuppressible) { 62 | const comment = makeComment({ 63 | commentType, 64 | diagnostics, 65 | jiraSlug, 66 | annotation: useIgnore ? "@ts-ignore" : "@ts-expect-error", 67 | }); 68 | const insertPos = position + addedLength; 69 | sourceFile.insertText(insertPos, comment); 70 | addedLength += comment.length; 71 | metrics.suppressed += 1; 72 | } 73 | } 74 | } 75 | 76 | type FileWriter = (file: SourceFile) => void; 77 | const defaultWriter = (file: SourceFile) => file.saveSync(); 78 | 79 | export async function autoSuppressErrors( 80 | { argv, project }: FixCommandState, 81 | writeFile: FileWriter = defaultWriter 82 | ) { 83 | const diagnosticsByFile: Map< 84 | SourceFile, 85 | Record 86 | > = new Map(); 87 | logger.info("Suppressing errors.."); 88 | const diagnostics = getDiagnostics(project); 89 | 90 | diagnostics.forEach((error) => { 91 | const sourceFile = error.getSourceFile(); 92 | 93 | if (!sourceFile) { 94 | return; 95 | } 96 | 97 | const errorStart = error.getStart(); 98 | if (!errorStart) { 99 | return; 100 | } 101 | 102 | const node = sourceFile.getDescendantAtPos(errorStart); 103 | const errorStartLine = node?.getStartLinePos(true); 104 | if (errorStartLine == null) { 105 | return; 106 | } 107 | 108 | // Find out which comment we should make 109 | const errorLine = sourceFile.getDescendantAtPos(errorStartLine); 110 | const commentsForFile = diagnosticsByFile.get(sourceFile) ?? {}; 111 | const commentsForLine = commentsForFile[errorStartLine] ?? { 112 | position: errorStartLine, 113 | commentType: [ 114 | ts.SyntaxKind.JsxElement, 115 | ts.SyntaxKind.JsxFragment, 116 | ts.SyntaxKind.JsxExpression, 117 | ts.SyntaxKind.JsxClosingElement, 118 | ].some((elementType) => errorLine?.getParentIfKind(elementType) != null) 119 | ? CommentType.Jsx 120 | : CommentType.Standard, 121 | diagnostics: [], 122 | }; 123 | 124 | commentsForLine.diagnostics.push(error); 125 | commentsForFile[errorStartLine] = commentsForLine; 126 | diagnosticsByFile.set(sourceFile, commentsForFile); 127 | }); 128 | 129 | const metrics: Metrics = { 130 | suppressed: 0, 131 | }; 132 | 133 | for (const [sourceFile, positions] of diagnosticsByFile) { 134 | addSuppressionsInFile(metrics, positions, sourceFile, project, argv); 135 | 136 | try { 137 | writeFile(sourceFile); 138 | } catch (e) { 139 | logger.warn( 140 | `Error when saving suppressed source file. Ensure that node_modules is not being type checked by your TSConfig. Error: ${e}.` 141 | ); 142 | } 143 | } 144 | 145 | logger.complete( 146 | `Suppressed ${metrics.suppressed} errors across ${diagnosticsByFile.size} files.` 147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /src/convert/jsx-spread/jsx-spread.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import traverse, { VisitNodeFunction } from "@babel/traverse"; 3 | import { TransformerInput } from "../transformer"; 4 | import { componentsWithSpreads } from "./components-with-spreads"; 5 | import { getLoc } from "../utils/common"; 6 | 7 | const classExtendsReactComponent = ({ 8 | superClass, 9 | superTypeParameters, 10 | }: t.ClassDeclaration) => { 11 | return ( 12 | t.isMemberExpression(superClass) && 13 | t.isIdentifier(superClass.object) && 14 | superClass.object.name === "React" && 15 | t.isIdentifier(superClass.property) && 16 | superClass.property.name === "Component" && 17 | t.isTSTypeParameterInstantiation(superTypeParameters) 18 | ); 19 | }; 20 | 21 | /** 22 | * Create a new intersection type between the defined props and the underlying spread component 23 | */ 24 | function getNewPropsType(propParam: t.TSType, componentSpreads: t.TSType[]) { 25 | const allPropTypes = t.tsIntersectionType(componentSpreads); 26 | 27 | const myKeyOfOperator = t.tsTypeOperator(propParam); 28 | myKeyOfOperator.operator = "keyof"; 29 | 30 | const omittedFromProps = t.tsTypeReference( 31 | t.identifier("Omit"), 32 | t.tsTypeParameterInstantiation([allPropTypes, myKeyOfOperator]) 33 | ); 34 | 35 | return t.tsIntersectionType([propParam, omittedFromProps]); 36 | } 37 | 38 | /** 39 | * Navigate a functional component to detect spreads 40 | */ 41 | const functionalVisitor: VisitNodeFunction< 42 | TransformerInput, 43 | t.FunctionDeclaration | t.ArrowFunctionExpression 44 | > = function (path) { 45 | const { node } = path; 46 | 47 | if (node.params.length === 0) { 48 | return; 49 | } 50 | 51 | const [propsParam] = node.params; 52 | 53 | if (!t.isIdentifier(propsParam)) { 54 | return; 55 | } 56 | 57 | if (!t.isTSTypeAnnotation(propsParam.typeAnnotation)) { 58 | return; 59 | } 60 | 61 | const localComponentsWithSpreads = componentsWithSpreads( 62 | path, 63 | propsParam.name 64 | ); 65 | 66 | if (localComponentsWithSpreads.length === 0) { 67 | return; 68 | } 69 | 70 | this.state.usedUtils = true; 71 | this.reporter.usedJSXSpread(this.state.config.filePath, getLoc(node)); 72 | 73 | propsParam.typeAnnotation.typeAnnotation = getNewPropsType( 74 | propsParam.typeAnnotation.typeAnnotation, 75 | localComponentsWithSpreads 76 | ); 77 | }; 78 | 79 | /** 80 | * In Flow, objects are inexact by default including React component props. 81 | * This means that any defined props are type-checked, but if you add a prop that isn't defined 82 | * Flow does no checks and lets you use it wrong. This causes an error in TypeScript which strictly checks props. 83 | * In practice, we found that a lot of components spread props onto their HTML component, like a div. 84 | * In those cases, it was common for teams to add props like className and have it spread through without any 85 | * Flow checking. This transform finds cases where props are passed through, and adds them to the component 86 | * automatically so they are strongly typed in TS. 87 | */ 88 | export function transformJsxSpread(transformerInput: TransformerInput) { 89 | const { state, file } = transformerInput; 90 | if (!state.config.convertJSXSpreads) { 91 | return; 92 | } 93 | 94 | traverse( 95 | file, 96 | { 97 | ClassDeclaration: { 98 | enter(path) { 99 | const { node } = path; 100 | 101 | if (!classExtendsReactComponent(node)) { 102 | return; 103 | } 104 | 105 | const componentState = { 106 | componentsWithSpreads: [] as Array, 107 | }; 108 | 109 | // Look for functions which have React Component Spreads in them... 110 | path.traverse( 111 | { 112 | ClassMethod(path) { 113 | this.componentsWithSpreads.push( 114 | ...componentsWithSpreads(path, "this.props") 115 | ); 116 | }, 117 | }, 118 | componentState 119 | ); 120 | 121 | if (componentState.componentsWithSpreads.length === 0) { 122 | return; 123 | } 124 | 125 | if (!node.superTypeParameters) { 126 | return; 127 | } 128 | const [propParam] = node.superTypeParameters.params; 129 | if (!propParam) { 130 | return; 131 | } 132 | if (!t.isTSTypeReference(propParam)) { 133 | return; 134 | } 135 | 136 | this.state.usedUtils = true; 137 | this.reporter.usedJSXSpread(this.state.config.filePath, getLoc(node)); 138 | 139 | node.superTypeParameters.params[0] = getNewPropsType( 140 | propParam, 141 | componentState.componentsWithSpreads 142 | ); 143 | }, 144 | }, 145 | FunctionDeclaration: functionalVisitor, 146 | ArrowFunctionExpression: functionalVisitor, 147 | }, 148 | undefined, 149 | transformerInput 150 | ); 151 | } 152 | -------------------------------------------------------------------------------- /src/fix/fix-type-exports/index.ts: -------------------------------------------------------------------------------- 1 | import type { Node } from "ts-morph"; 2 | import { SourceFile, ts, ExportDeclaration } from "ts-morph"; 3 | import MigrationReporter from "../../runner/migration-reporter"; 4 | import { getParentUntil } from "../ts-node-traversal"; 5 | import { jsonFormatter } from "../../runner/migration-reporter/formatters/json-formatter"; 6 | import { logger } from "../../runner/logger"; 7 | import { stdOutFormatter } from "../../runner/migration-reporter/formatters/std-out-formatter"; 8 | import { FixCommandState, getDiagnostics } from "../state"; 9 | 10 | const REEXPORTED_TYPE_ERROR = 1205; // See: https://www.typescriptlang.org/tsconfig#isolatedModules 11 | 12 | function extractTypeOnlyExports( 13 | sourceFile: SourceFile, 14 | exportNode: ExportDeclaration, 15 | typeOnlyIdentifiers: string[] 16 | ) { 17 | const structure = exportNode.getStructure(); 18 | 19 | const extractedTypeOnlyExports = []; 20 | 21 | // Remove any type-only identifiers from this export statement: 22 | for (const namedExport of exportNode.getNamedExports()) { 23 | if (typeOnlyIdentifiers.includes(namedExport.getName())) { 24 | extractedTypeOnlyExports.push(namedExport.getStructure()); 25 | namedExport.remove(); 26 | } 27 | } 28 | 29 | // If we removed all identifiers from this export declaration without removing 30 | // the declaration itself, remove it (rather than leaving 'import * from "…";'): 31 | if (!exportNode.wasForgotten() && !exportNode.hasNamedExports()) { 32 | exportNode.remove(); 33 | } 34 | 35 | // Add new type-only export for this group of identifiers: 36 | // TODO: Should insert directly after original export (instead of EOF). 37 | sourceFile.addExportDeclaration({ 38 | isTypeOnly: true, 39 | namedExports: extractedTypeOnlyExports, 40 | moduleSpecifier: structure.moduleSpecifier, 41 | }); 42 | } 43 | 44 | function fixTypeOnlyExports(sourceFile: SourceFile, identifiers: Node[]) { 45 | const identifiersByExport = identifiers.reduce((exportMap, node: Node) => { 46 | const exportNode = getParentUntil( 47 | node, 48 | ts.isExportDeclaration 49 | ) as ExportDeclaration; 50 | if (exportNode) { 51 | const identifiers = exportMap.get(exportNode) || []; 52 | exportMap.set(exportNode, [...identifiers, node]); 53 | } 54 | return exportMap; 55 | }, new Map()); 56 | 57 | for (const [exportNode, identifiers] of identifiersByExport.entries()) { 58 | const typeOnlyIdentifiers = identifiers.map((id) => id.getText()); 59 | const containsAllTypeOnlyExports = exportNode 60 | .getNamedExports() 61 | .map((namedExport) => namedExport.getName()) 62 | .every((name) => typeOnlyIdentifiers.includes(name)); 63 | 64 | if (containsAllTypeOnlyExports) { 65 | exportNode.setIsTypeOnly(true); 66 | } else { 67 | extractTypeOnlyExports(sourceFile, exportNode, typeOnlyIdentifiers); 68 | } 69 | } 70 | } 71 | 72 | type FileWriter = (file: SourceFile) => void; 73 | const defaultWriter = (file: SourceFile) => file.saveSync(); 74 | 75 | export async function fixTypeExports( 76 | { argv, migrationReporter, project }: FixCommandState, 77 | writeFile: FileWriter = defaultWriter 78 | ) { 79 | logger.info("Checking TypeScript export types"); 80 | logger.warn(`[Experimental] This transformation is experimental.`); 81 | 82 | const initialDiagnostics = getDiagnostics(project); 83 | 84 | const diagnostics = initialDiagnostics.filter( 85 | (diagnostic) => diagnostic.getCode() === REEXPORTED_TYPE_ERROR 86 | ); 87 | 88 | logger.info(`${diagnostics.length} type-export diagnostics received.`); 89 | 90 | const invalidTypeExportIdentifiersByFile = diagnostics.reduce( 91 | (sourceFileMap, error) => { 92 | const sourceFile = error.getSourceFile(); 93 | const location = error.getStart(); 94 | if (!sourceFile || !location) { 95 | return sourceFileMap; 96 | } 97 | 98 | const node = sourceFile.getDescendantAtPos(location); 99 | if (node) { 100 | const nodes = sourceFileMap.get(sourceFile) || []; 101 | sourceFileMap.set(sourceFile, [...nodes, node]); 102 | } 103 | 104 | return sourceFileMap; 105 | }, 106 | new Map() 107 | ); 108 | 109 | logger.info(`Fixing mismatched type exports.`); 110 | 111 | invalidTypeExportIdentifiersByFile.forEach((nodes, sourceFile) => { 112 | for (const node of nodes) { 113 | migrationReporter.typeExports( 114 | sourceFile.getFilePath(), 115 | node.getStartLineNumber() 116 | ); 117 | } 118 | 119 | fixTypeOnlyExports(sourceFile, nodes); 120 | 121 | try { 122 | writeFile(sourceFile); 123 | } catch (e) { 124 | logger.warn( 125 | `Error when saving suppressed source file. Ensure that node_modules is not being type checked by your TSConfig. Error: ${e}.` 126 | ); 127 | } 128 | }); 129 | 130 | logger.info(`Done fixing type exports.`); 131 | 132 | await MigrationReporter.logReport( 133 | migrationReporter.generateReport(), 134 | argv.format === "json" ? jsonFormatter(argv.output) : stdOutFormatter 135 | ); 136 | } 137 | -------------------------------------------------------------------------------- /src/convert/flow/type-at-pos.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import * as recast from "recast"; 3 | import * as recastFlowParser from "recast/parsers/flow"; 4 | import { executeFlowTypeAtPos } from "./execute-type-at-pos"; 5 | import { State } from "../../runner/state"; 6 | import MigrationReporter from "../../runner/migration-reporter"; 7 | import { transformPrivateTypes } from "../private-types"; 8 | /** 9 | * Runs Flow to get the inferred type at a given position. Uses the Flow server so once the Flow 10 | * server is running this should be pretty fast. We use this to add explicit annotations where Flow 11 | * needs some help. 12 | * 13 | * Queued so that we don’t overload the Flow server. 14 | */ 15 | export function flowTypeAtPos( 16 | state: State, 17 | location: t.SourceLocation, 18 | migrationReporter: MigrationReporter 19 | ): Promise { 20 | let resolve: (value: string) => void; 21 | let reject: (error: unknown) => void; 22 | 23 | const promise = new Promise((_resolve, _reject) => { 24 | resolve = _resolve; 25 | reject = _reject; 26 | }); 27 | 28 | flowTypeAtPosQueue.push({ 29 | filePath: state.config.filePath, 30 | location, 31 | migrationReporter, 32 | resolve: resolve!, 33 | reject: reject!, 34 | }); 35 | 36 | if (processingFlowTypeAtPosQueue === false) { 37 | processFlowTypeAtPosQueue(); 38 | } 39 | 40 | return promise 41 | .then((stdOut) => 42 | processFlowTypeAtPosStdout(stdOut, migrationReporter, state, location) 43 | ) 44 | .catch(() => { 45 | return null; 46 | }); 47 | } 48 | 49 | /** 50 | * Are we currently processing `flowTypeAtPosQueue`? If so then don’t call 51 | * `processFlowTypeAtPosQueue()` a second time. 52 | */ 53 | let processingFlowTypeAtPosQueue = false; 54 | 55 | /** 56 | * Holds all the pending `flowTypeAtPos()` calls. 57 | */ 58 | const flowTypeAtPosQueue: Array<{ 59 | filePath: string; 60 | location: t.SourceLocation; 61 | migrationReporter: MigrationReporter; 62 | resolve: (value: string) => void; 63 | reject: (error: unknown) => void; 64 | }> = []; 65 | 66 | /** 67 | * Continually process all the entries in `flowTypeAtPosQueue`. 68 | */ 69 | function processFlowTypeAtPosQueue() { 70 | processingFlowTypeAtPosQueue = true; 71 | 72 | const entry = flowTypeAtPosQueue.shift(); 73 | 74 | if (!entry) { 75 | processingFlowTypeAtPosQueue = false; 76 | return; 77 | } 78 | 79 | executeFlowTypeAtPos(entry.filePath, entry.location).then( 80 | (value) => { 81 | // Start the next asynchronous `flow type-at-pos` request before resolving the entry! 82 | // When we resolve the entry some synchronous work will be done to parse the result. 83 | // We can do that work concurrently while `flow type-at-pos` works. 84 | processFlowTypeAtPosQueue(); 85 | entry.resolve(value); 86 | }, 87 | (value) => { 88 | processFlowTypeAtPosQueue(); 89 | entry.migrationReporter.flowFailToParse( 90 | entry.filePath, 91 | entry.location, 92 | value as Error 93 | ); 94 | entry.reject(value); 95 | } 96 | ); 97 | } 98 | 99 | /** 100 | * Processes the standard output of `flow type-at-pos`. 101 | */ 102 | function processFlowTypeAtPosStdout( 103 | stdout: string, 104 | migrationReporter: MigrationReporter, 105 | state: State, 106 | location: t.SourceLocation 107 | ): t.FlowType | null { 108 | // Sanitize stdout... 109 | // `any(implicit)` -> `any` 110 | let { type } = JSON.parse(stdout) as { type: string }; 111 | const isExplicit = type.includes("(explicit)"); 112 | type = type.replace("(explicit)", ""); 113 | type = type.replace("(implicit)", ""); 114 | 115 | // Flow does not know the type at this location. 116 | if (type === "unknown") { 117 | migrationReporter.unknownFlowType(state.config.filePath, location); 118 | return null; 119 | } 120 | 121 | if (type === "any" && !isExplicit) { 122 | migrationReporter.anyFlowType(state.config.filePath, location); 123 | return null; 124 | } 125 | 126 | // The inferred Flow type is really big, a human probably would not have written it. Don’t 127 | // return the type. 128 | if (type.length >= 100) { 129 | migrationReporter.complexFlowType(state.config.filePath, location, type); 130 | return null; 131 | } 132 | 133 | try { 134 | // Parse the Flow type and return it! 135 | const flowType: t.File = recast.parse(`type T = ${type};`, { 136 | parser: recastFlowParser, 137 | }); 138 | 139 | // Run our pre-processing step on the types 140 | transformPrivateTypes({ 141 | file: flowType, 142 | reporter: migrationReporter, 143 | state, 144 | }); 145 | 146 | const node = (flowType.program.body[0] as t.TypeAlias).right; 147 | 148 | // `function f(x: string | any)` 149 | if ( 150 | node.type === "UnionTypeAnnotation" && 151 | node.types.some((unionType) => unionType.type === "AnyTypeAnnotation") 152 | ) { 153 | migrationReporter.anyFlowType(state.config.filePath, location); 154 | return null; 155 | } 156 | 157 | return node; 158 | } catch (e) { 159 | migrationReporter.flowFailToParse( 160 | state.config.filePath, 161 | location, 162 | e as Error 163 | ); 164 | return null; 165 | } 166 | } 167 | -------------------------------------------------------------------------------- /src/convert/utils/testing.ts: -------------------------------------------------------------------------------- 1 | import * as recast from "recast"; 2 | import * as t from "@babel/types"; 3 | import * as recastFlowParser from "recast/parsers/flow"; 4 | import { Project, SourceFile } from "ts-morph"; 5 | import { recastOptions } from "../../runner/process-batch"; 6 | import { runTransforms } from "../../runner/run-transforms"; 7 | import MigrationReporter from "../../runner/migration-reporter"; 8 | import { defaultTransformerChain } from "../default-transformer-chain"; 9 | import { watermarkTransformRunner } from "../transform-runners"; 10 | import { Transformer } from "../transformer"; 11 | import { State } from "../../runner/state"; 12 | import { ConfigurableTypeProvider } from "./configurable-type-provider"; 13 | import { FixCommandCliArgs } from "../../cli/arguments"; 14 | import { FixCommandState } from "../../fix/state"; 15 | 16 | const MockedMigrationReporter = 17 | MigrationReporter as unknown as jest.Mock; 18 | 19 | /** 20 | * Runs the default set of transforms 21 | * 22 | * @param {string} code 23 | * @param {State} [state] 24 | * @return {*} 25 | */ 26 | const transform = async (code: string, state?: State) => { 27 | state = state ?? stateBuilder(); 28 | 29 | const transforms = defaultTransformerChain; 30 | return transformRunner(code, state, transforms); 31 | }; 32 | 33 | /** 34 | * Runs a single watermark transform 35 | * 36 | * @param {string} code 37 | * @param {State} [state] 38 | * @return {*} 39 | */ 40 | const watermarkTransform = async (code: string, state?: State) => { 41 | const filePath = "./fake/test.js"; 42 | const isTestFile = filePath.endsWith(".test.js"); 43 | state = 44 | state ?? 45 | stateBuilder({ 46 | config: { 47 | filePath, 48 | isTestFile, 49 | watermark: "@typescriptify", 50 | watermarkMessage: ` 51 | THIS FILE IS AUTOMATICALLY GENERATED. Do not edit this file. 52 | If you want to manually write flow types for this file, 53 | remove the @typescriptify annotation and this comment block. 54 | `, 55 | convertJSXSpreads: false, 56 | }, 57 | }); 58 | 59 | const transforms = [watermarkTransformRunner]; 60 | return transformRunner(code, state, transforms); 61 | }; 62 | 63 | const transformRunner = async ( 64 | code: string, 65 | state: State, 66 | transforms: readonly Transformer[] 67 | ) => { 68 | const reporter = new MigrationReporter(); 69 | const file: t.File = recast.parse(code, { 70 | parser: recastFlowParser, 71 | }); 72 | 73 | await runTransforms(reporter, state, file, transforms); 74 | 75 | return recast.print(file, recastOptions).code; 76 | }; 77 | 78 | const expectMigrationReporterMethodCalled = ( 79 | methodName: keyof MigrationReporter 80 | ) => { 81 | const didCall = MockedMigrationReporter.mock.instances.some((reporter) => { 82 | return ( 83 | (reporter[methodName] as jest.Mock) 84 | .mock.calls.length >= 1 85 | ); 86 | }); 87 | expect(didCall).toBe(true); 88 | }; 89 | 90 | const expectMigrationReporterMethodNotCalled = ( 91 | methodName: keyof MigrationReporter 92 | ) => { 93 | const didCall = MockedMigrationReporter.mock.instances.some((reporter) => { 94 | return ( 95 | (reporter[methodName] as jest.Mock) 96 | .mock.calls.length >= 1 97 | ); 98 | }); 99 | expect(didCall).toBe(false); 100 | }; 101 | 102 | type DeepPartialOverride = { 103 | [P in keyof T]?: DeepPartialOverride; 104 | }; 105 | 106 | type StateLessConfigurableTypeProvider = DeepPartialOverride< 107 | Omit 108 | > & 109 | Partial>; 110 | 111 | const stateBuilder = ( 112 | stateOverrides: StateLessConfigurableTypeProvider = {} 113 | ): State => { 114 | const filePath = "./fake/test.js"; 115 | const isTestFile = filePath.endsWith(".test.js"); 116 | const typeProvider = 117 | stateOverrides.configurableTypeProvider ?? 118 | new ConfigurableTypeProvider({ 119 | useStrictAnyFunctionType: false, 120 | useStrictAnyObjectType: false, 121 | }); 122 | 123 | return { 124 | hasJsx: false, 125 | usedUtils: false, 126 | ...stateOverrides, 127 | config: { 128 | filePath, 129 | isTestFile, 130 | watermark: "", 131 | watermarkMessage: "", 132 | convertJSXSpreads: false, 133 | dropImportExtensions: false, 134 | keepPrivateTypes: false, 135 | forceTSX: false, 136 | disableFlow: false, 137 | ...stateOverrides.config, 138 | }, 139 | configurableTypeProvider: typeProvider, 140 | }; 141 | }; 142 | 143 | type ResultDictionary = { [filename: string]: string }; 144 | 145 | export function createOutputRecorder(): [ 146 | ResultDictionary, 147 | (file: SourceFile) => void 148 | ] { 149 | const results: ResultDictionary = {}; 150 | 151 | function recordResult(file: SourceFile) { 152 | results[file.getBaseName()] = file.getFullText(); 153 | } 154 | 155 | return [results, recordResult]; 156 | } 157 | 158 | const getTestFixState = (argv: FixCommandCliArgs): FixCommandState => { 159 | const migrationReporter = new MigrationReporter(); 160 | const project = new Project({ 161 | tsConfigFilePath: argv.config, 162 | }); 163 | 164 | return { argv, migrationReporter, project }; 165 | }; 166 | 167 | export { 168 | transform, 169 | watermarkTransform, 170 | expectMigrationReporterMethodCalled, 171 | expectMigrationReporterMethodNotCalled, 172 | stateBuilder, 173 | MockedMigrationReporter, 174 | getTestFixState, 175 | }; 176 | -------------------------------------------------------------------------------- /src/runner/process-batch.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs-extra"; 2 | import path from "path"; 3 | import * as t from "@babel/types"; 4 | import * as recast from "recast"; 5 | import { Options } from "recast"; 6 | import * as recastFlowParser from "recast/parsers/flow"; 7 | 8 | import { runTransforms } from "./run-transforms"; 9 | import MigrationReporter from "./migration-reporter"; 10 | import { ConvertCommandCliArgs } from "../cli/arguments"; 11 | import { defaultTransformerChain } from "../convert/default-transformer-chain"; 12 | import { watermarkTransformRunner } from "../convert/transform-runners"; 13 | import { State } from "./state"; 14 | import { ConfigurableTypeProvider } from "../convert/utils/configurable-type-provider"; 15 | import { hasDeclaration } from "../convert/utils/common"; 16 | import { FlowFileList, FlowFileType } from "./find-flow-files"; 17 | import { logger } from "./logger"; 18 | 19 | export const FlowCommentRegex = /((\/){2,} ?)*@flow.*\n+/; 20 | 21 | export const recastOptions: Options = { 22 | quote: "single", 23 | trailingComma: true, 24 | objectCurlySpacing: false, 25 | }; 26 | 27 | /** 28 | * Process a batch of files, running transforms and renaming files 29 | */ 30 | export async function processBatchAsync( 31 | reporter: MigrationReporter, 32 | filePaths: FlowFileList, 33 | options: ConvertCommandCliArgs 34 | ) { 35 | await Promise.all( 36 | filePaths.map(async ({ filePath, fileType }) => { 37 | try { 38 | if (fileType === FlowFileType.NO_FLOW && options.skipNoFlow) { 39 | return; 40 | } 41 | const fileBuffer = await fs.readFile(filePath); 42 | 43 | const fileText = fileBuffer.toString("utf8"); 44 | 45 | // Count the number of lines 46 | try { 47 | const lineCount = (fileText.match(/\n/g) || "").length + 1; 48 | reporter.reportLineCount(lineCount); 49 | } catch { 50 | // Line counting is not important. Ignore error. 51 | } 52 | 53 | const file: t.File = recast.parse(fileText, { 54 | parser: recastFlowParser, 55 | }); 56 | const isTestFile = filePath.endsWith(".test.js"); 57 | if (hasDeclaration(file)) { 58 | reporter.foundDeclarationFile(filePath); 59 | return; 60 | } 61 | const state: State = { 62 | hasJsx: false, 63 | usedUtils: false, 64 | config: { 65 | filePath, 66 | isTestFile, 67 | watermark: options.tag, 68 | watermarkMessage: options.message, 69 | convertJSXSpreads: options.handleSpreadReactProps, 70 | dropImportExtensions: options.dropImportExtensions, 71 | keepPrivateTypes: options.keepPrivateTypes, 72 | forceTSX: options.forceTSX, 73 | disableFlow: options.disableFlow, 74 | }, 75 | configurableTypeProvider: new ConfigurableTypeProvider({ 76 | useStrictAnyFunctionType: options.useStrictAnyFunctionType, 77 | useStrictAnyObjectType: options.useStrictAnyObjectType, 78 | }), 79 | }; 80 | const transforms = Array.from(defaultTransformerChain); 81 | 82 | if (options.watermark) { 83 | transforms.push(watermarkTransformRunner); 84 | } 85 | 86 | await runTransforms(reporter, state, file, transforms); 87 | if (!options.write) { 88 | return; 89 | } 90 | 91 | // Write the migrated file to a temporary file since we’re just testing at the moment. 92 | const newFileText = recast.print(file, recastOptions).code; 93 | 94 | const targetFilePath = 95 | options.target === "" 96 | ? filePath 97 | : filePath.replace( 98 | path.normalize(filePath), 99 | path.normalize(options.target) 100 | ); 101 | 102 | const tsFilePath = targetFilePath.replace( 103 | /\.jsx?$/, 104 | state.hasJsx || options.forceTSX ? ".tsx" : ".ts" 105 | ); 106 | 107 | if (isTestFile) { 108 | const fileName = path.basename(filePath); 109 | const tsFileName = path.basename(tsFilePath); 110 | const directoryPath = path.dirname(filePath); 111 | // since we are in a test file there may be a snapshot we also have to rename. 112 | const originalSnapPath = `${fileName}.snap`; 113 | const snapshotPath = path.join( 114 | directoryPath, 115 | "__snapshots__", 116 | originalSnapPath 117 | ); 118 | if (await fs.pathExists(snapshotPath)) { 119 | const newSnapPath = path.join( 120 | directoryPath, 121 | "__snapshots__", 122 | `${tsFileName}.snap` 123 | ); 124 | if (snapshotPath !== newSnapPath) { 125 | reporter.migrateSnapFile( 126 | filePath, 127 | originalSnapPath, 128 | snapshotPath 129 | ); 130 | try { 131 | await fs.move(snapshotPath, newSnapPath); 132 | } catch (e) { 133 | reporter.error(filePath, e); 134 | } 135 | } 136 | } 137 | } 138 | 139 | await fs.outputFile(targetFilePath, newFileText); 140 | reporter.success(targetFilePath, state.hasJsx); 141 | } catch (error) { 142 | // Report errors, but don’t crash the worker... 143 | reporter.error(filePath, error); 144 | logger.error(`Error found in ${filePath}: ${error}`, error); 145 | } 146 | }) 147 | ); 148 | } 149 | -------------------------------------------------------------------------------- /src/test/regression/regression.test.ts: -------------------------------------------------------------------------------- 1 | import fs from "fs"; 2 | import { anyTypeAnnotation } from "@babel/types"; 3 | import * as ts from "typescript"; 4 | import dedent from "dedent"; 5 | import { flowTypeAtPos } from "../../convert/flow/type-at-pos"; 6 | import { transform } from "../../convert/utils/testing"; 7 | 8 | jest.mock("../../convert/flow/type-at-pos"); 9 | 10 | const mockedFlowTypeAtPos = >( 11 | flowTypeAtPos 12 | ); 13 | mockedFlowTypeAtPos.mockResolvedValue(anyTypeAnnotation()); 14 | 15 | function generateTypeScriptErrorMessages(received: ts.Diagnostic[]) { 16 | const errorMessages = received.map((diagnostic) => { 17 | const { line, character } = ts.getLineAndCharacterOfPosition( 18 | diagnostic.file!, 19 | diagnostic.start! 20 | ); 21 | const errorMessage = ts.flattenDiagnosticMessageText( 22 | diagnostic.messageText, 23 | "\n" 24 | ); 25 | return `${diagnostic.file!.fileName} (${line + 1},${ 26 | character + 1 27 | }): ${errorMessage}`; 28 | }); 29 | return dedent`Received TypeScript errors: 30 | ${dedent( 31 | [...new Set(errorMessages)].reduce( 32 | (returnMessage, error, n) => `${returnMessage + (n + 1)}. ${error}\n\n`, 33 | "" 34 | ) 35 | )}`; 36 | } 37 | 38 | // Custom error message for comparing diagnostics from TS 39 | expect.extend({ 40 | toHaveNoTypeScriptErrors(received: ts.Diagnostic[]) { 41 | if (received.length === 0) { 42 | return { 43 | message: () => "Did not receive any TypeScript errors!", 44 | pass: true, 45 | }; 46 | } else { 47 | return { 48 | message: () => generateTypeScriptErrorMessages(received), 49 | pass: false, 50 | }; 51 | } 52 | }, 53 | }); 54 | 55 | describe("Regression tests", () => { 56 | test("flow_typescript_differences", async () => { 57 | const transformedData = await getData( 58 | `${__dirname}/../test-files/flow_typescript_differences.js` 59 | ); 60 | 61 | expect(transformedData).toMatchSnapshot(); 62 | 63 | const transformationResult = compileTypeScriptCode( 64 | "flow_typescript_differences", 65 | replaceImports(transformedData), 66 | ["es2015", "dom"] 67 | ); 68 | 69 | expect(transformationResult.diagnostics).toHaveNoTypeScriptErrors(); 70 | expect(transformationResult.success).toBeTruthy(); 71 | }); 72 | }); 73 | 74 | async function getData(filename: string) { 75 | const data = fs.readFileSync(filename, { 76 | encoding: "utf8", 77 | flag: "r", 78 | }); 79 | 80 | const transformedData = await transform(data.toString()); 81 | 82 | // Remove flow annotation for cleanliness 83 | return transformedData.replace(/\/\/ @flow.*\n+/, ""); 84 | } 85 | 86 | // The in memory typescript compiler can't seem to find imported modules. 87 | // This hack to replace imports with absolute paths seems to work. 88 | // Don't apply this before the snapshot, or your snapshot will have absoulte paths 89 | function replaceImports(data: string) { 90 | return data 91 | .replace( 92 | /flow-to-typescript-codemod/, 93 | require.resolve("../../../flow.d").replace(/.d.ts/, "") 94 | ) 95 | .replace( 96 | /'react'/gi, 97 | `'${require.resolve( 98 | "../../../node_modules/@types/react/index.d" 99 | )}'`.replace(/.d.ts/, "") 100 | ); 101 | } 102 | 103 | function compileTypeScriptCode( 104 | sourceFileName: string, 105 | code: string, 106 | libs: string[] 107 | ): { 108 | success: boolean; 109 | diagnostics: ts.Diagnostic[]; 110 | } { 111 | const sourceFileNameWithExtension = `${sourceFileName}.ts`; 112 | const options: ts.CompilerOptions = { 113 | ...ts.getDefaultCompilerOptions(), 114 | ...{ 115 | // We set this so we can indicate an error based on whether it emitted or not 116 | noEmitOnError: true, 117 | // This makes inputting test samples much easier 118 | noUnusedLocals: false, 119 | esModuleInterop: true, 120 | module: ts.ModuleKind.CommonJS, 121 | }, 122 | }; 123 | 124 | // Real Host is based on the file system 125 | const fileSystemHost = ts.createCompilerHost(options, true); 126 | 127 | // Create a dummy TS source file based on the input code 128 | const dummySourceFile = ts.createSourceFile( 129 | sourceFileNameWithExtension, 130 | code, 131 | ts.ScriptTarget.Latest 132 | ); 133 | 134 | // Proxy the functions we care about to allow reading files from memory 135 | const proxiedMemoryHost: ts.CompilerHost = { 136 | ...fileSystemHost, 137 | ...{ 138 | fileExists: (filePath) => 139 | filePath === sourceFileNameWithExtension || 140 | fileSystemHost.fileExists(filePath), 141 | getSourceFile: ( 142 | fileName, 143 | languageVersion, 144 | onError, 145 | shouldCreateNewSourceFile 146 | ) => 147 | fileName === sourceFileNameWithExtension 148 | ? dummySourceFile 149 | : fileSystemHost.getSourceFile( 150 | fileName, 151 | languageVersion, 152 | onError, 153 | shouldCreateNewSourceFile 154 | ), 155 | readFile: (filePath) => 156 | filePath === sourceFileNameWithExtension 157 | ? code 158 | : fileSystemHost.readFile(filePath), 159 | writeFile: () => { 160 | // Do nothing 161 | }, 162 | }, 163 | }; 164 | 165 | // Load TypeScript Libs 166 | const rootNames = libs.map((lib) => 167 | require.resolve(`typescript/lib/lib.${lib}.d.ts`) 168 | ); 169 | const program = ts.createProgram( 170 | rootNames.concat([sourceFileNameWithExtension]), 171 | options, 172 | proxiedMemoryHost 173 | ); 174 | const emitResult = program.emit(); 175 | const diagnostics = ts.getPreEmitDiagnostics(program); 176 | return { 177 | success: !emitResult.emitSkipped, 178 | diagnostics: emitResult.diagnostics.concat(diagnostics), 179 | }; 180 | } 181 | -------------------------------------------------------------------------------- /src/convert/migrate/object-members.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import MigrationReporter from "../../runner/migration-reporter"; 3 | import { State } from "../../runner/state"; 4 | import { inheritLocAndComments, buildTSIdentifier } from "../utils/common"; 5 | import { migrateFunctionParameters } from "./function-parameter"; 6 | import { migrateType } from "./type"; 7 | import { migrateTypeParameterDeclaration } from "./type-parameter"; 8 | 9 | export function migrateObjectMember( 10 | reporter: MigrationReporter, 11 | state: State, 12 | flowMember: 13 | | t.ObjectTypeProperty 14 | | t.ObjectTypeIndexer 15 | | t.ObjectTypeCallProperty 16 | | t.ObjectTypeInternalSlot 17 | ): t.TSTypeElement { 18 | const tsMember = actuallyMigrateObjectMember(reporter, state, flowMember); 19 | inheritLocAndComments(flowMember, tsMember); 20 | return tsMember; 21 | } 22 | 23 | function actuallyMigrateObjectMember( 24 | reporter: MigrationReporter, 25 | state: State, 26 | flowMember: 27 | | t.ObjectTypeProperty 28 | | t.ObjectTypeIndexer 29 | | t.ObjectTypeCallProperty 30 | | t.ObjectTypeInternalSlot 31 | ): t.TSTypeElement { 32 | switch (flowMember.type) { 33 | case "ObjectTypeProperty": { 34 | // type Test = { $Key: string }; 35 | if ( 36 | flowMember.key.type === "Identifier" && 37 | flowMember.key.name.startsWith("$") 38 | ) { 39 | reporter.objectPropertyWithInternalName( 40 | state.config.filePath, 41 | flowMember.loc! 42 | ); 43 | } 44 | 45 | // { -test: boolean } 46 | if (flowMember.variance && flowMember.variance.kind !== "plus") { 47 | reporter.objectPropertyWithMinusVariance( 48 | state.config.filePath, 49 | flowMember.loc! 50 | ); 51 | } 52 | 53 | if (!(flowMember.kind || flowMember.kind === "init")) { 54 | throw new Error( 55 | `Unsupported object type property kind: ${JSON.stringify( 56 | flowMember.kind 57 | )}` 58 | ); 59 | } 60 | if (flowMember.proto) { 61 | throw new Error( 62 | "Did not expect any Flow properties with `proto` set to true." 63 | ); 64 | } 65 | if (flowMember.static) { 66 | throw new Error( 67 | "Did not expect any Flow properties with `static` set to true." 68 | ); 69 | } 70 | 71 | const tsValue = migrateType(reporter, state, flowMember.value); 72 | 73 | if (!flowMember.method) { 74 | const tsPropertySignature = t.tsPropertySignature( 75 | flowMember.key, 76 | t.tsTypeAnnotation(tsValue) 77 | ); 78 | 79 | tsPropertySignature.computed = flowMember.key.type !== "Identifier"; 80 | tsPropertySignature.optional = !!flowMember.optional; 81 | tsPropertySignature.readonly = flowMember.variance 82 | ? flowMember.variance.kind === "plus" 83 | : null; 84 | 85 | return tsPropertySignature; 86 | } else { 87 | if (tsValue.type !== "TSFunctionType") { 88 | throw new Error( 89 | `Unexpected AST node: ${JSON.stringify(tsValue.type)}` 90 | ); 91 | } 92 | 93 | const tsMethodSignature = t.tsMethodSignature( 94 | flowMember.key, 95 | tsValue.typeParameters, 96 | tsValue.parameters, 97 | tsValue.typeAnnotation 98 | ); 99 | 100 | tsMethodSignature.computed = flowMember.key.type !== "Identifier"; 101 | tsMethodSignature.optional = !!flowMember.optional; 102 | 103 | return tsMethodSignature; 104 | } 105 | } 106 | 107 | case "ObjectTypeIndexer": { 108 | if (flowMember.variance && flowMember.variance.kind !== "plus") 109 | reporter.objectPropertyWithMinusVariance( 110 | state.config.filePath, 111 | flowMember.loc! 112 | ); 113 | 114 | if (flowMember.static) 115 | throw new Error( 116 | "Did not expect any Flow properties with `static` set to true." 117 | ); 118 | 119 | const tsIndexSignature = t.tsIndexSignature( 120 | [ 121 | buildTSIdentifier( 122 | flowMember.id ? flowMember.id.name : "key", 123 | null, 124 | t.tsTypeAnnotation(migrateType(reporter, state, flowMember.key)) 125 | ), 126 | ], 127 | t.tsTypeAnnotation(migrateType(reporter, state, flowMember.value)) 128 | ); 129 | tsIndexSignature.readonly = flowMember.variance 130 | ? flowMember.variance.kind === "plus" 131 | : null; 132 | return tsIndexSignature; 133 | } 134 | 135 | case "ObjectTypeCallProperty": 136 | const flowType = flowMember.value; 137 | if (flowType.type !== "FunctionTypeAnnotation") { 138 | const currentParams = t.restElement(t.identifier("args")); 139 | currentParams.typeAnnotation = t.tsTypeAnnotation(t.tsUnknownKeyword()); 140 | const callSignature = t.tsCallSignatureDeclaration( 141 | null, 142 | [currentParams], 143 | t.tsTypeAnnotation(t.tsUnknownKeyword()) 144 | ); 145 | // Add the comment here, so it will get copied over at the end of this block. 146 | // @ts-expect-error comments type differs between recast and babel 147 | flowMember.comments.push({ 148 | type: "CommentBlock", 149 | value: 150 | "The flowtype for this callable object was not able to be migrated to TypeScript. Please update these types.", 151 | leading: true, 152 | trailing: false, 153 | loc: null, 154 | }); 155 | return callSignature; 156 | } 157 | const typeParams = flowType.typeParameters 158 | ? migrateTypeParameterDeclaration( 159 | reporter, 160 | state, 161 | flowType.typeParameters 162 | ) 163 | : null; 164 | const functionParams = migrateFunctionParameters( 165 | reporter, 166 | state, 167 | flowType 168 | ); 169 | 170 | return t.tsCallSignatureDeclaration( 171 | typeParams, 172 | functionParams, 173 | t.tsTypeAnnotation( 174 | migrateType(reporter, state, flowType.returnType, { 175 | returnType: true, 176 | }) 177 | ) 178 | ); 179 | 180 | case "ObjectTypeInternalSlot": 181 | throw new Error( 182 | `Unsupported AST node: ${JSON.stringify(flowMember.type)}` 183 | ); 184 | 185 | default: { 186 | const never: { type: string } = flowMember; 187 | throw new Error(`Unrecognized AST node: ${JSON.stringify(never.type)}`); 188 | } 189 | } 190 | } 191 | -------------------------------------------------------------------------------- /src/convert/utils/common.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import traverse, { NodePath, Scope } from "@babel/traverse"; 3 | import { types } from "recast"; 4 | import { TransformerInput } from "../transformer"; 5 | import MigrationReporter from "../../runner/migration-reporter"; 6 | import { logger } from "../../runner/logger"; 7 | 8 | /** 9 | * Determine whether the file contains any JSX 10 | * @param file : File source to check 11 | */ 12 | export function hasJSX({ file }: TransformerInput): boolean { 13 | let found = false; 14 | traverse(file, { 15 | JSXElement() { 16 | found = true; 17 | }, 18 | JSXFragment() { 19 | found = true; 20 | }, 21 | }); 22 | 23 | return found; 24 | } 25 | 26 | /** 27 | * Determine whether function block returns null 28 | * Helpful when typing React functional components 29 | */ 30 | export function hasNullReturn( 31 | body: t.BlockStatement, 32 | scope: Scope | undefined, 33 | parentPath: NodePath | null | undefined 34 | ): boolean { 35 | let found = false; 36 | traverse( 37 | body, 38 | { 39 | ReturnStatement(path) { 40 | if (path.node.argument?.type === "NullLiteral") { 41 | found = true; 42 | } 43 | }, 44 | }, 45 | scope, 46 | parentPath 47 | ); 48 | 49 | return found; 50 | } 51 | 52 | /** 53 | * Determine whether the file contains any type declarations 54 | * @param file : File source to check 55 | */ 56 | export function hasDeclaration(file: t.File): boolean { 57 | let found = false; 58 | traverse(file, { 59 | DeclareModule() { 60 | found = true; 61 | }, 62 | DeclareExportDeclaration() { 63 | found = true; 64 | }, 65 | DeclareClass() { 66 | found = true; 67 | }, 68 | }); 69 | 70 | return found; 71 | } 72 | 73 | /** 74 | * Construct a TS type identifier. 75 | * @param name 76 | * @param optional 77 | * @param typeAnnotation 78 | * @returns 79 | */ 80 | export function buildTSIdentifier( 81 | name: string, 82 | optional?: boolean | null, 83 | typeAnnotation?: t.TSTypeAnnotation | null 84 | ): t.Identifier { 85 | const identifier = t.identifier(name); 86 | if (optional != null) identifier.optional = optional; 87 | if (typeAnnotation != null) identifier.typeAnnotation = typeAnnotation; 88 | return identifier; 89 | } 90 | 91 | /** 92 | * Is this a literal expression? Includes literal objects and functions. 93 | */ 94 | export function isComplexLiteral(expression: t.Expression): boolean { 95 | if (t.isLiteral(expression)) { 96 | return true; 97 | } 98 | if (expression.type === "Identifier" && expression.name === "undefined") { 99 | return true; 100 | } 101 | 102 | if (expression.type === "ArrayExpression") { 103 | for (const element of expression.elements) { 104 | if (element === null) { 105 | continue; 106 | } 107 | if (element.type === "SpreadElement") { 108 | if (!isComplexLiteral(element.argument)) { 109 | return false; 110 | } else { 111 | continue; 112 | } 113 | } 114 | if (!isComplexLiteral(element)) { 115 | return false; 116 | } 117 | } 118 | return true; 119 | } 120 | 121 | if (expression.type === "ObjectExpression") { 122 | for (const property of expression.properties) { 123 | if (property.type === "ObjectMethod") { 124 | return false; 125 | } else if (property.type === "SpreadElement") { 126 | return false; 127 | } else { 128 | if (property.computed && !isComplexLiteral(property.key)) { 129 | return false; 130 | } 131 | if ( 132 | t.isExpression(property.value) && 133 | !isComplexLiteral(property.value) 134 | ) { 135 | return false; 136 | } 137 | } 138 | } 139 | return true; 140 | } 141 | 142 | return false; 143 | } 144 | 145 | /** 146 | * Are we inside `createReactClass()`? 147 | */ 148 | export function isInsideCreateReactClass(path: NodePath): boolean { 149 | if ( 150 | path.node.type === "CallExpression" && 151 | path.node.callee.type === "Identifier" && 152 | path.node.callee.name === "createReactClass" 153 | ) { 154 | return true; 155 | } 156 | 157 | if (path.parentPath) { 158 | return isInsideCreateReactClass(path.parentPath); 159 | } 160 | 161 | return false; 162 | } 163 | 164 | /** 165 | * Copies the location and comments of one node to a new node. 166 | */ 167 | export function inheritLocAndComments(oldNode: t.Node, newNode: t.Node) { 168 | newNode.loc = oldNode.loc; 169 | 170 | // Recast uses a different format for comments then Babel. 171 | if ("comments" in oldNode) { 172 | // @ts-expect-error comments doesn't exist on babel type 173 | newNode.comments = oldNode.comments; 174 | delete oldNode.comments; 175 | } 176 | } 177 | 178 | export function addCommentsAtHeadOfNode( 179 | rootNode: types.namedTypes.Node | undefined, 180 | comments: (types.namedTypes.CommentBlock | types.namedTypes.CommentLine)[] 181 | ) { 182 | if (rootNode !== undefined) { 183 | rootNode.comments = rootNode.comments || []; 184 | rootNode.comments.unshift(...comments); 185 | } else { 186 | logger.warn(`Cannot add comments ${comments} to empty node!`); 187 | } 188 | } 189 | 190 | export function addEmptyLineInProgramPath(path: NodePath) { 191 | path.unshiftContainer("body", t.noop()); 192 | } 193 | 194 | /** 195 | * Recast uses a different format for comments. We need to manually copy them over to the new node. 196 | * We also attach the old location so that Recast prints it at the same place. 197 | * 198 | * https://github.com/benjamn/recast/issues/572 199 | */ 200 | export function replaceWith( 201 | path: NodePath, 202 | node: t.Node, 203 | filePath: string, 204 | reporter: MigrationReporter 205 | ) { 206 | inheritLocAndComments(path.node, node); 207 | try { 208 | path.replaceWith(node); 209 | } catch (e) { 210 | // Catch the error so conversion of the file can continue. 211 | reporter.error(filePath, e); 212 | } 213 | } 214 | 215 | /** 216 | * Tries to return the nearest LOC, and returns a default if not found. 217 | */ 218 | export function getLoc( 219 | node: TNodeType 220 | ): t.SourceLocation { 221 | return ( 222 | node.loc ?? 223 | (node as t.FunctionDeclaration).body?.loc ?? { 224 | start: { 225 | line: 0, 226 | column: 0, 227 | }, 228 | end: { 229 | line: 0, 230 | column: 0, 231 | }, 232 | } 233 | ); 234 | } 235 | 236 | export const GlobalTypes = { 237 | TimeoutID: "number", 238 | IntervalID: "number", 239 | ImmediateID: "number", 240 | immediateID: "number", 241 | AnimationFrameID: "number", 242 | RequestOptions: "RequestInit", 243 | } as const; 244 | 245 | export const LiteralTypes = { 246 | String: "string", 247 | Number: "number", 248 | Boolean: "boolean", 249 | Symbol: "symbol", 250 | } as const; 251 | 252 | export const JEST_MOCK_METHODS = ["mock", "requireActual"]; 253 | -------------------------------------------------------------------------------- /src/runner/find-flow-files.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * This file is forked from a file I (@calebmer) wrote when working on a large Flow migration 3 | * project: [`flow-upgrade/src/findFlowFiles.js`][1]. It needed to be hyper-optimized for Facebook 4 | * scale. Why not reuse it? 5 | * 6 | * [1]: https://github.com/facebook/flow/blob/6491b1ac744dcac82ad07f4d9ff9deb6b977275d/packages/flow-upgrade/src/findFlowFiles.js 7 | */ 8 | 9 | import path from "path"; 10 | import fs from "fs-extra"; 11 | import ignore from "ignore"; 12 | import MigrationReporter from "./migration-reporter"; 13 | 14 | /** 15 | * How many bytes we should look at for the Flow pragma. 16 | */ 17 | const PRAGMA_BYTES = 5000; 18 | export enum FlowFileType { 19 | FLOW, 20 | NO_FLOW, 21 | } 22 | 23 | export type FlowFileList = Array<{ filePath: string; fileType: FlowFileType }>; 24 | 25 | /** 26 | * Finds all of the Flow files in the provided directory as efficiently as 27 | * possible. 28 | * 29 | * NOTE: If we use promises then Node.js will quickly run out of memory on large 30 | * codebases (Facebook scale). Instead we use the callback API. 31 | */ 32 | export function findFlowFilesAsync( 33 | rootDirectory: string, 34 | ignoredDirectories: Array, 35 | reporter: MigrationReporter 36 | ): Promise { 37 | return new Promise((_resolve, _reject) => { 38 | // Tracks whether or not we have rejected our promise. 39 | let rejected = false; 40 | // How many asynchronous tasks are waiting at the moment. 41 | let waiting = 0; 42 | // All the valid file paths that we have found. 43 | const filePaths: FlowFileList = []; 44 | // Track ignored files 45 | const ig = ignore().add(ignoredDirectories); 46 | 47 | // Begin the recursion! 48 | processDirectory(rootDirectory, reporter); 49 | 50 | /** 51 | * Process a directory by looking at all of its entries and recursing 52 | * through child directories as is appropriate. 53 | */ 54 | function processDirectory(directory: string, reporter: MigrationReporter) { 55 | // If we were rejected then we should not continue. 56 | if (rejected === true) { 57 | return; 58 | } 59 | // We are now waiting on this asynchronous task. 60 | waiting++; 61 | // Read the directory... 62 | fs.readdir(directory, (error, fileNames) => { 63 | if (error) { 64 | return reject(error); 65 | } 66 | // Process every file name that we got from reading the directory. 67 | for (let i = 0; i < fileNames.length; i++) { 68 | processFilePath(directory, fileNames[i], reporter); 69 | } 70 | // We are done with this async task. 71 | done(); 72 | }); 73 | } 74 | 75 | /** 76 | * Process a directory file path by seeing if it is a directory and either 77 | * recursing or adding it to `filePaths`. 78 | */ 79 | function processFilePath( 80 | directory: string, 81 | fileName: string, 82 | reporter: MigrationReporter 83 | ) { 84 | // If we were rejected then we should not continue. 85 | if (rejected === true) { 86 | return; 87 | } 88 | // We are now waiting on this asynchronous task. 89 | waiting++; 90 | // Get the file path for this file. 91 | const filePath = path.join(directory, fileName); 92 | // Check whether file should be skipped 93 | if (ig.ignores(filePath)) { 94 | done(); 95 | return; 96 | } 97 | // Get the stats for the file. 98 | fs.lstat(filePath, (error, stats) => { 99 | if (error) { 100 | return reject(error); 101 | } 102 | // If this is a directory... 103 | if (stats.isDirectory()) { 104 | // ...and it is not an ignored directory... 105 | if (fileName !== "node_modules" && fileName !== "transpiled") { 106 | // ...then recursively process the directory. 107 | processDirectory(filePath, reporter); 108 | } 109 | } else if (stats.isFile()) { 110 | // Otherwise if this is a JavaScript file... 111 | if (fileName.endsWith(".js") || fileName.endsWith(".jsx")) { 112 | // Then process the file path as JavaScript. 113 | processJavaScriptFilePath(filePath, stats.size, reporter); 114 | } 115 | } 116 | // We are done with this async task 117 | done(); 118 | }); 119 | } 120 | 121 | /** 122 | * Check if a file path really is a Flow file by looking for the @flow 123 | * header pragma. 124 | */ 125 | function processJavaScriptFilePath( 126 | filePath: string, 127 | fileByteSize: number, 128 | reporter: MigrationReporter 129 | ) { 130 | // If we were rejected then we should not continue. 131 | if (rejected === true) { 132 | return; 133 | } 134 | // We are now waiting on this asynchronous task. 135 | waiting++; 136 | // Open the file path. 137 | fs.open(filePath, "r", (error, file) => { 138 | if (error) { 139 | return reject(error); 140 | } 141 | // Get the smaller of our pragma chars constant and the file byte size. 142 | const bytes = Math.min(PRAGMA_BYTES, fileByteSize); 143 | // Create the buffer we will read to. 144 | const buffer = Buffer.alloc(bytes); 145 | // Read a set number of bytes from the file. 146 | fs.read(file, buffer, 0, bytes, 0, (error) => { 147 | if (error) { 148 | return reject(error); 149 | } 150 | // If the buffer has the @flow pragma then add the file path to our 151 | // final file paths array. 152 | if (buffer.includes("@flow")) { 153 | filePaths.push({ filePath, fileType: FlowFileType.FLOW }); 154 | } else if (buffer.includes("@noflow")) { 155 | filePaths.push({ filePath, fileType: FlowFileType.NO_FLOW }); 156 | reporter.foundNoFlowAnnotation(filePath); 157 | } else { 158 | reporter.foundNonFlowfile(filePath); 159 | } 160 | // Close the file. 161 | fs.close(file, (error) => { 162 | if (error) { 163 | return reject(error); 164 | } 165 | // We are done with this async task 166 | done(); 167 | }); 168 | }); 169 | }); 170 | } 171 | 172 | /** 173 | * Our implementation of resolve that will only actually resolve if we are 174 | * done waiting everywhere. 175 | */ 176 | function done() { 177 | // We don't care if we were rejected. 178 | if (rejected === true) { 179 | return; 180 | } 181 | // Decrement the number of async tasks we are waiting on. 182 | waiting--; 183 | // If we are finished waiting then we want to resolve our promise. 184 | if (waiting <= 0) { 185 | if (waiting === 0) { 186 | _resolve(filePaths); 187 | } else { 188 | reject(new Error(`Expected a positive number: ${waiting}`)); 189 | } 190 | } 191 | } 192 | 193 | /** 194 | * Our implementation of reject that also sets `rejected` to false. 195 | */ 196 | function reject(error: unknown) { 197 | rejected = true; 198 | _reject(error); 199 | } 200 | }); 201 | } 202 | -------------------------------------------------------------------------------- /src/convert/function-visitor.test.ts: -------------------------------------------------------------------------------- 1 | import { stringTypeAnnotation } from "@babel/types"; 2 | import dedent from "dedent"; 3 | import { flowTypeAtPos } from "./flow/type-at-pos"; 4 | import { 5 | expectMigrationReporterMethodCalled, 6 | expectMigrationReporterMethodNotCalled, 7 | MockedMigrationReporter, 8 | stateBuilder, 9 | transform, 10 | } from "./utils/testing"; 11 | 12 | jest.mock("../runner/migration-reporter/migration-reporter.ts"); 13 | 14 | jest.mock("./flow/type-at-pos"); 15 | const mockedFlowTypeAtPos = >( 16 | flowTypeAtPos 17 | ); 18 | 19 | describe("parameter inference", () => { 20 | afterEach(mockedFlowTypeAtPos.mockReset); 21 | describe("required inference", () => { 22 | it("provides inference for ArrowFunctionExpressions", async () => { 23 | const src = `(a, b) => {a + b};`; 24 | const expected = `(a: string, b: string) => {a + b};`; 25 | mockedFlowTypeAtPos.mockResolvedValue(stringTypeAnnotation()); 26 | expect(await transform(src)).toBe(expected); 27 | }); 28 | 29 | it("skips inference if disableFlow is set", async () => { 30 | const src = `(a, b) => {a + b};`; 31 | const expected = `(a: unknown, b: unknown) => {a + b};`; 32 | // mockedFlowTypeAtPos.mockResolvedValue(stringTypeAnnotation()); 33 | expect( 34 | await transform(src, stateBuilder({ config: { disableFlow: true } })) 35 | ).toBe(expected); 36 | }); 37 | 38 | it("provides inference for FunctionDeclarations", async () => { 39 | const src = `function fn(a, b) {return a + b};`; 40 | const expected = `function fn(a: string, b: string) {return a + b};`; 41 | mockedFlowTypeAtPos.mockResolvedValue(stringTypeAnnotation()); 42 | expect(await transform(src)).toBe(expected); 43 | }); 44 | 45 | it("provides inference for FunctionExpressions", async () => { 46 | const src = `const fn = function(a, b) {return a + b};`; 47 | const expected = `const fn = function(a: string, b: string) {return a + b};`; 48 | mockedFlowTypeAtPos.mockResolvedValue(stringTypeAnnotation()); 49 | expect(await transform(src)).toBe(expected); 50 | }); 51 | 52 | it("provides inference for ClassMethods", async () => { 53 | const src = dedent`class MyClass { 54 | method(value): void {} 55 | }`; 56 | const expected = dedent`class MyClass { 57 | method(value: string): void {} 58 | }`; 59 | mockedFlowTypeAtPos.mockResolvedValue(stringTypeAnnotation()); 60 | expect(await transform(src)).toBe(expected); 61 | }); 62 | 63 | describe("test files", () => { 64 | it("uses `any` type instead of parameter inference for test files", async () => { 65 | const src = `(a, b) => {a + b};`; 66 | const expected = `(a: any, b: any) => {a + b};`; 67 | 68 | expect( 69 | await transform( 70 | src, 71 | stateBuilder({ 72 | config: { 73 | filePath: "./fake/test.js", 74 | isTestFile: true, 75 | watermark: "@test", 76 | watermarkMessage: `Test message`, 77 | }, 78 | }) 79 | ) 80 | ).toBe(expected); 81 | expect(mockedFlowTypeAtPos).not.toBeCalled(); 82 | }); 83 | }); 84 | }); 85 | 86 | describe("unnecessary inference", () => { 87 | it("does not provide inference on ArrowFunctionExpressions within CallExpressions", async () => { 88 | const src = `const r = [1, 2, 3].map(a => a + 1);`; 89 | const expected = `const r = [1, 2, 3].map(a => a + 1);`; 90 | expect(await transform(src)).toBe(expected); 91 | expect(mockedFlowTypeAtPos).not.toBeCalled(); 92 | }); 93 | it("does not provide inference on FunctionDeclarations within CallExpressions", async () => { 94 | const src = `const r = [1, 2, 3].map(function fn(a) {return a + 1});`; 95 | const expected = `const r = [1, 2, 3].map(function fn(a) {return a + 1});`; 96 | expect(await transform(src)).toBe(expected); 97 | expect(mockedFlowTypeAtPos).not.toBeCalled(); 98 | }); 99 | }); 100 | }); 101 | 102 | describe("async functions", () => { 103 | afterEach(MockedMigrationReporter.mockReset); 104 | 105 | it("does not affect correctly type async functions", async () => { 106 | const src = dedent` 107 | async function simpleFunction(): Promise {} 108 | const simpleFunction2 = async (): Promise => {}; 109 | `; 110 | const expected = dedent` 111 | async function simpleFunction(): Promise {} 112 | const simpleFunction2 = async (): Promise => {}; 113 | `; 114 | expect(await transform(src)).toBe(expected); 115 | expectMigrationReporterMethodNotCalled("asyncFunctionReturnType"); 116 | }); 117 | 118 | it("does transform arrow functions", async () => { 119 | const src = dedent` 120 | const simpleFunction = async (): Type => {}; 121 | `; 122 | const expected = dedent` 123 | const simpleFunction = async (): Promise => {}; 124 | `; 125 | expect(await transform(src)).toBe(expected); 126 | expectMigrationReporterMethodCalled("asyncFunctionReturnType"); 127 | }); 128 | 129 | it("does transform function declarations", async () => { 130 | const src = dedent` 131 | async function simpleFunction(): Type {} 132 | `; 133 | const expected = dedent` 134 | async function simpleFunction(): Promise {} 135 | `; 136 | expect(await transform(src)).toBe(expected); 137 | expectMigrationReporterMethodCalled("asyncFunctionReturnType"); 138 | }); 139 | 140 | it("keeps namespaces around", async () => { 141 | const src = dedent` 142 | async function simpleFunction(): Type {} 143 | `; 144 | const expected = dedent` 145 | async function simpleFunction(): Promise> {} 146 | `; 147 | expect(await transform(src)).toBe(expected); 148 | expectMigrationReporterMethodCalled("asyncFunctionReturnType"); 149 | }); 150 | }); 151 | 152 | describe("assigning required fields an optional value", () => { 153 | afterEach(MockedMigrationReporter.mockReset); 154 | 155 | it("does not affect basic functions", async () => { 156 | const src = dedent` 157 | async function simpleFunction(): Promise {} 158 | `; 159 | 160 | await transform(src); 161 | 162 | expectMigrationReporterMethodNotCalled("requiredPropInOptionalAssignment"); 163 | }); 164 | 165 | it("warns when a value is marked as required but assigned to an empty object", async () => { 166 | const src = dedent` 167 | function a({blah = false}: {blah: boolean} = {}) {} 168 | `; 169 | 170 | await transform(src); 171 | 172 | expectMigrationReporterMethodCalled("requiredPropInOptionalAssignment"); 173 | }); 174 | 175 | it("does not warn the property is optional", async () => { 176 | const src = dedent` 177 | function a({blah = false}: {blah?: boolean} = {}) {} 178 | `; 179 | 180 | await transform(src); 181 | 182 | expectMigrationReporterMethodNotCalled("requiredPropInOptionalAssignment"); 183 | }); 184 | 185 | describe("arrow functions with object returns", () => { 186 | // const f1 = (): any => ({}); 187 | it("should keep parentheses around arrow functions that return objects", async () => { 188 | const src = dedent` 189 | const f1 = (arg1: string): any => ({}); 190 | const f2 = (arg1: string) => ({}); 191 | const f3 = async (arg1: string): Promise => ({}); 192 | const f4 = (arg1: T) => ({}); 193 | const f5 = (arg1: T): T => ({}); 194 | const f6 = (arg1: T): any => ({}); 195 | class Cls1 { f4 = (arg1: string): any => ({}) } 196 | class Cls2 { f5 = async (arg1: string): Promise => ({}) } 197 | class Cls3 { f6 = (arg1: string) => ({}) } 198 | const f7 = (arg1: string): () => Record => () => ({}); 199 | const f8 = (arg1: string): () => string => (): string => ({}); 200 | const f9 = (arg1: string): any => ({foo: 'bar'}); 201 | const f10 = (arg1: string): any => ({foo() {}}); 202 | var f11 = {foo: (arg1: string): object => ({})} 203 | `; 204 | 205 | expect(await transform(src)).toBe(src); 206 | }); 207 | }); 208 | }); 209 | -------------------------------------------------------------------------------- /src/convert/function-visitor.ts: -------------------------------------------------------------------------------- 1 | import * as t from "@babel/types"; 2 | import { NodePath } from "@babel/traverse"; 3 | import { 4 | FunctionDeclaration, 5 | FunctionExpression, 6 | ArrowFunctionExpression, 7 | ClassMethod, 8 | } from "@babel/types"; 9 | import MigrationReporter from "../runner/migration-reporter"; 10 | import { State } from "../runner/state"; 11 | import { annotateParamsWithFlowTypeAtPos } from "./flow/annotate-params"; 12 | import { handleAsyncReturnType } from "./utils/handle-async-function-return-type"; 13 | import { getLoc } from "./utils/common"; 14 | 15 | type FunctionVisitorProps = { 16 | awaitPromises: Array>; 17 | reporter: MigrationReporter; 18 | state: State; 19 | }; 20 | 21 | export const functionVisitor = < 22 | TNodeType extends 23 | | FunctionExpression 24 | | FunctionDeclaration 25 | | ArrowFunctionExpression 26 | | ClassMethod 27 | >({ 28 | awaitPromises, 29 | reporter, 30 | state, 31 | }: FunctionVisitorProps) => ({ 32 | enter(path: NodePath) { 33 | // Add Flow’s inferred type for all unannotated function parameters... 34 | 35 | // `function f(x, y, z)` → `function f(x: any, y: any, z: any)` 36 | // 37 | // TypeScript can’t infer unannotated function parameters unlike Flow. We accept lower 38 | // levels of soundness in type files. We’ll manually annotate non-test files. 39 | if (state.config.isTestFile) { 40 | for (const param of path.node.params) { 41 | if (!(param as t.Identifier).typeAnnotation) { 42 | (param as t.Identifier).typeAnnotation = t.tsTypeAnnotation( 43 | t.tsAnyKeyword() 44 | ); 45 | } 46 | } 47 | return; 48 | } 49 | 50 | // In Flow, class constructors can have a return type (usually void). 51 | // This is an error in TS. 52 | if ( 53 | path.node.type === "ClassMethod" && 54 | t.isIdentifier(path.node.key) && 55 | path.node.key.name === "constructor" 56 | ) { 57 | delete path.node.returnType; 58 | } 59 | 60 | // In Flow, if a class has type parameters (class Foo), then static methods can use those 61 | // types in their declaration. In TypeScript, the static method needs those same parameters declared 62 | // static foo(bar: T) 63 | // in order to use them. When we see a static method, we check if the class has parameters and apply them. 64 | if (path.node.type === "ClassMethod" && path.node.static) { 65 | if (path.parentPath.type === "ClassBody") { 66 | const classDeclaration = path.parentPath.parentPath?.node; 67 | if (classDeclaration && classDeclaration.type === "ClassDeclaration") { 68 | if ( 69 | classDeclaration.typeParameters && 70 | classDeclaration.typeParameters.type === 71 | "TypeParameterDeclaration" && 72 | classDeclaration.typeParameters.params.length > 0 73 | ) { 74 | // The class has type parameters, if the static function doesn't declare them we need to declare them 75 | if (!path.node.typeParameters) { 76 | path.node.typeParameters = classDeclaration.typeParameters; 77 | } 78 | } 79 | } 80 | } 81 | } 82 | 83 | // If parent is a CallExpression, we are passing a function into a function. TS typically 84 | // can infer accurate arguments that will cause fewer issues than types inferred by Flow, 85 | // as well as maintain the original intention of the author 86 | if (path.parentPath.node.type !== "CallExpression") { 87 | awaitPromises.push( 88 | annotateParamsWithFlowTypeAtPos(reporter, state, path.node.params, path) 89 | ); 90 | } 91 | 92 | if (path.node.async) { 93 | handleAsyncReturnType( 94 | path.node, 95 | reporter, 96 | state.config.filePath, 97 | getLoc(path.node) 98 | ); 99 | } 100 | }, 101 | exit(path: NodePath) { 102 | let optional = true; 103 | // `function f(a?: T, b: U)` → `function f(a: T | undefined, b: U)` 104 | for (const param of path.node.params.slice().reverse()) { 105 | let paramIsOptional = false; 106 | 107 | if (param.type === "AssignmentPattern") { 108 | paramIsOptional = true; 109 | if (param.left.type === "Identifier" && param.left.optional) { 110 | param.left.optional = false; 111 | } else if ( 112 | t.isObjectExpression(param.right) && 113 | t.isObjectPattern(param.left) 114 | ) { 115 | if (param.right.properties.length === 0) { 116 | // If we are assigning a param to an empty object: function f({stuff}: {stuff: Type} = {}) 117 | // check if the type annotation assigned to the left is an object type annotation 118 | if ( 119 | t.isTypeAnnotation(param.left.typeAnnotation) && 120 | t.isObjectTypeAnnotation(param.left.typeAnnotation.typeAnnotation) 121 | ) { 122 | // if it is go through each property and check if any aren't optional 123 | for (const prop of param.left.typeAnnotation.typeAnnotation 124 | .properties) { 125 | // for each required property report a warning 126 | if (t.isObjectTypeProperty(prop) && !prop.optional) { 127 | reporter.requiredPropInOptionalAssignment( 128 | state.config.filePath, 129 | param.loc! 130 | ); 131 | } 132 | } 133 | } 134 | } 135 | } 136 | } 137 | if (param.type === "Identifier") { 138 | paramIsOptional = 139 | param.optional || 140 | (param.typeAnnotation?.type === "TypeAnnotation" && 141 | param.typeAnnotation?.typeAnnotation.type === 142 | "NullableTypeAnnotation"); 143 | } 144 | 145 | if (!paramIsOptional) { 146 | optional = false; 147 | } else if (!optional) { 148 | const identifier: Partial = ( 149 | param.type === "AssignmentPattern" ? param.left : param 150 | ) as t.Identifier; 151 | delete identifier.optional; 152 | 153 | if ( 154 | identifier.typeAnnotation && 155 | identifier.typeAnnotation.type === "TSTypeAnnotation" 156 | ) { 157 | if (identifier.typeAnnotation.typeAnnotation.type === "TSUnionType") { 158 | identifier.typeAnnotation.typeAnnotation.types.push( 159 | t.tsUndefinedKeyword() 160 | ); 161 | } else { 162 | identifier.typeAnnotation.typeAnnotation = t.tsUnionType([ 163 | identifier.typeAnnotation.typeAnnotation, 164 | t.tsUndefinedKeyword(), 165 | ]); 166 | } 167 | } else if ( 168 | identifier.typeAnnotation && 169 | identifier.typeAnnotation.type === "TypeAnnotation" 170 | ) { 171 | if ( 172 | identifier.typeAnnotation.typeAnnotation.type === 173 | "NullableTypeAnnotation" 174 | ) { 175 | identifier.typeAnnotation.typeAnnotation = t.unionTypeAnnotation([ 176 | identifier.typeAnnotation.typeAnnotation.typeAnnotation, 177 | t.nullLiteralTypeAnnotation(), 178 | t.genericTypeAnnotation(t.identifier("undefined")), 179 | ]); 180 | } else { 181 | identifier.typeAnnotation.typeAnnotation = t.unionTypeAnnotation([ 182 | identifier.typeAnnotation.typeAnnotation, 183 | t.nullLiteralTypeAnnotation(), 184 | t.genericTypeAnnotation(t.identifier("undefined")), 185 | ]); 186 | } 187 | } 188 | } 189 | } 190 | 191 | // let us fix return types for functions that return objects 192 | if ( 193 | (t.isObjectExpression(path.node.body) && 194 | (path.node.returnType || path.node.typeParameters)) || 195 | (path.node.extra?.parenthesized && t.isExpression(path.node.body)) 196 | ) { 197 | path.node.extra = { ...path.node.extra, parenthesized: false }; 198 | path.node.body = t.parenthesizedExpression(path.node.body); 199 | } 200 | }, 201 | }); 202 | -------------------------------------------------------------------------------- /src/convert/flow/annotate-params.test.ts: -------------------------------------------------------------------------------- 1 | import dedent from "dedent"; 2 | import { stateBuilder, transform } from "../utils/testing"; 3 | import { executeFlowTypeAtPos } from "./execute-type-at-pos"; 4 | 5 | jest.mock("./execute-type-at-pos.ts"); 6 | const mockedExecuteFlowTypeAtPos = < 7 | jest.MockedFunction 8 | >executeFlowTypeAtPos; 9 | 10 | describe("implied params", () => { 11 | afterEach(mockedExecuteFlowTypeAtPos.mockReset); 12 | 13 | it("does annotate function expressions without a type annotation", async () => { 14 | const src = dedent` 15 | const add = function (num1, num2) { 16 | return num1 + num2; 17 | } 18 | `; 19 | const expected = dedent` 20 | const add = function (num1: number, num2: number) { 21 | return num1 + num2; 22 | } 23 | `; 24 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 25 | expect(await transform(src)).toBe(expected); 26 | expect(mockedExecuteFlowTypeAtPos).toHaveBeenCalledTimes(2); 27 | }); 28 | 29 | it("does not annotate function expressions with a type annotation", async () => { 30 | const src = dedent` 31 | type Type = (num1: number, num2: number) => number; 32 | const add: Type = function (num1, num2) { 33 | return num1 + num2; 34 | } 35 | `; 36 | const expected = dedent` 37 | type Type = (num1: number, num2: number) => number; 38 | const add: Type = function (num1, num2) { 39 | return num1 + num2; 40 | } 41 | `; 42 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 43 | expect(await transform(src)).toBe(expected); 44 | expect(mockedExecuteFlowTypeAtPos).not.toHaveBeenCalled(); 45 | }); 46 | 47 | it("does annotate function expressions without a type annotation", async () => { 48 | const src = dedent` 49 | const add = (num1, num2) => num1 + num2; 50 | `; 51 | const expected = dedent` 52 | const add = (num1: number, num2: number) => num1 + num2; 53 | `; 54 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 55 | expect(await transform(src)).toBe(expected); 56 | expect(mockedExecuteFlowTypeAtPos).toHaveBeenCalledTimes(2); 57 | }); 58 | 59 | it("does not annotate function expressions with a type annotation", async () => { 60 | const src = dedent` 61 | type Type = (num1: number, num2: number) => number; 62 | const add: Type = (num1, num2) => num1 + num2; 63 | `; 64 | 65 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 66 | expect(await transform(src)).toBe(src); 67 | expect(mockedExecuteFlowTypeAtPos).not.toHaveBeenCalled(); 68 | }); 69 | 70 | it("does not annotate function expressions within an object property", async () => { 71 | const src = dedent` 72 | test({ 73 | propLink: (props) => {}, 74 | foo: 'bar' 75 | }); 76 | `; 77 | 78 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 79 | expect(await transform(src)).toBe(src); 80 | expect(mockedExecuteFlowTypeAtPos).not.toHaveBeenCalled(); 81 | }); 82 | 83 | it("does annotate arrow expressions without a type annotation", async () => { 84 | const src = dedent` 85 | const add = (num1, num2) => num1 + num2; 86 | `; 87 | const expected = dedent` 88 | const add = (num1: number, num2: number) => num1 + num2; 89 | `; 90 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 91 | expect(await transform(src)).toBe(expected); 92 | expect(mockedExecuteFlowTypeAtPos).toHaveBeenCalledTimes(2); 93 | }); 94 | 95 | it("does not annotate arrow expressions with a type annotation", async () => { 96 | const src = dedent` 97 | type Type = (num1: number, num2: number) => number; 98 | const add: Type = (num1, num2) => num1 + num2; 99 | `; 100 | 101 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 102 | expect(await transform(src)).toBe(src); 103 | expect(mockedExecuteFlowTypeAtPos).not.toHaveBeenCalled(); 104 | }); 105 | 106 | it("does not annotate function expressions inside a React component", async () => { 107 | const src = dedent` 108 | num1 + num2} /> 109 | `; 110 | 111 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 112 | expect(await transform(src)).toBe(src); 113 | expect(mockedExecuteFlowTypeAtPos).toHaveBeenCalledTimes(0); 114 | }); 115 | 116 | it("does annotate an untyped method inside a class", async () => { 117 | const src = dedent` 118 | class Foo { 119 | add(num1, num2) { 120 | return num1 + num2; 121 | } 122 | } 123 | `; 124 | const expected = dedent` 125 | class Foo { 126 | add(num1: number, num2: number) { 127 | return num1 + num2; 128 | } 129 | } 130 | `; 131 | 132 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 133 | expect(await transform(src)).toBe(expected); 134 | expect(mockedExecuteFlowTypeAtPos).toHaveBeenCalledTimes(2); 135 | }); 136 | 137 | it("does not annotate a typed method inside a class", async () => { 138 | const src = dedent` 139 | class Foo { 140 | add: Type = function(num1: number, num2: number) { 141 | return num1 + num2; 142 | }; 143 | }; 144 | `; 145 | 146 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 147 | expect(await transform(src)).toBe(src); 148 | expect(mockedExecuteFlowTypeAtPos).not.toHaveBeenCalled(); 149 | }); 150 | 151 | it("does not annotate a function defined inside a call expression", async () => { 152 | const src = dedent` 153 | fn((num1, num2) => num1 + num2); 154 | `; 155 | 156 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 157 | expect(await transform(src)).toBe(src); 158 | expect(mockedExecuteFlowTypeAtPos).not.toHaveBeenCalled(); 159 | }); 160 | 161 | it("does annotate object methods in a React.createReactClass", async () => { 162 | const src = dedent` 163 | React.createReactClass({ 164 | add(num1, num2) { 165 | return num1 + num2; 166 | } 167 | }); 168 | `; 169 | const expected = dedent` 170 | React.createReactClass({ 171 | add(num1: number, num2: number) { 172 | return num1 + num2; 173 | } 174 | }); 175 | `; 176 | 177 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 178 | expect(await transform(src)).toBe(expected); 179 | expect(mockedExecuteFlowTypeAtPos).toHaveBeenCalledTimes(2); 180 | }); 181 | 182 | it("does not annotate functions with a type assertion", async () => { 183 | const src = dedent` 184 | // @flow 185 | const add = (((num1, num2) => num1 + num2): Type); 186 | `; 187 | 188 | const expected = dedent` 189 | const add = (((num1, num2) => (num1 + num2)) as Type); 190 | `; 191 | 192 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 193 | expect(await transform(src)).toBe(expected); 194 | expect(mockedExecuteFlowTypeAtPos).not.toHaveBeenCalled(); 195 | }); 196 | 197 | // If the parent is a function with a return type we don't need to type parameters 198 | it("does not annotate functions within a return type", async () => { 199 | const src = dedent` 200 | // @flow 201 | (): TestFunction => (param) => { 202 | return null; 203 | }; 204 | `; 205 | 206 | const expected = dedent` 207 | (): TestFunction => (param) => { 208 | return null; 209 | }; 210 | `; 211 | 212 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 213 | expect(await transform(src)).toBe(expected); 214 | expect(mockedExecuteFlowTypeAtPos).not.toHaveBeenCalled(); 215 | }); 216 | 217 | it("does annotate arrow functions without a return type", async () => { 218 | const src = dedent` 219 | // @flow 220 | () => (param) => { 221 | return null; 222 | }; 223 | `; 224 | 225 | const expected = dedent` 226 | () => (param: number) => { 227 | return null; 228 | }; 229 | `; 230 | 231 | mockedExecuteFlowTypeAtPos.mockResolvedValue('{"type": "number"}'); 232 | expect(await transform(src)).toBe(expected); 233 | expect(mockedExecuteFlowTypeAtPos).toHaveBeenCalled(); 234 | }); 235 | 236 | it("skips flow checking if disable flow is passed in", async () => { 237 | const src = dedent` 238 | const add = function (num1, num2) { 239 | return num1 + num2; 240 | } 241 | `; 242 | const expected = dedent` 243 | const add = function (num1: unknown, num2: unknown) { 244 | return num1 + num2; 245 | } 246 | `; 247 | 248 | expect( 249 | await transform(src, stateBuilder({ config: { disableFlow: true } })) 250 | ).toBe(expected); 251 | }); 252 | }); 253 | --------------------------------------------------------------------------------