From 1458aeec9d219054ce77c10941e99c8dc89a22e5 Mon Sep 17 00:00:00 2001 From: Bob Ippolito Date: Sun, 23 Feb 2025 07:32:00 -0800 Subject: [PATCH] [*] Feature: Add linter to check that flow types are consistent with typescript types (#7230) --- package-lock.json | 100 +++++++++++++++++++++ package.json | 3 + scripts/lint-flow-types.js | 174 +++++++++++++++++++++++++++++++++++++ 3 files changed, 277 insertions(+) create mode 100644 scripts/lint-flow-types.js diff --git a/package-lock.json b/package-lock.json index b9c10a6b4..5a68f7a3d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -69,6 +69,7 @@ "glob": "^10.4.1", "google-closure-compiler": "^20220202.0.0", "gzip-size": "^6.0.0", + "hermes-estree": "^0.26.0", "hermes-parser": "^0.26.0", "hermes-transform": "^0.26.0", "husky": "^7.0.1", @@ -87,6 +88,7 @@ "rollup": "^4.22.4", "tmp": "^0.2.1", "ts-jest": "^29.1.2", + "ts-morph": "^25.0.1", "ts-node": "^10.9.1", "typedoc": "^0.25.12", "typescript": "^5.4.5", @@ -8824,6 +8826,41 @@ "node": ">=10.13.0" } }, + "node_modules/@ts-morph/common": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.26.1.tgz", + "integrity": "sha512-Sn28TGl/4cFpcM+jwsH1wLncYq3FtN/BIpem+HOygfBWPT5pAeS5dB4VFVzV8FbnOKHpDLZmvAl4AjPEev5idA==", + "dev": true, + "dependencies": { + "fast-glob": "^3.3.2", + "minimatch": "^9.0.4", + "path-browserify": "^1.0.1" + } + }, + "node_modules/@ts-morph/common/node_modules/brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "dependencies": { + "balanced-match": "^1.0.0" + } + }, + "node_modules/@ts-morph/common/node_modules/minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "dependencies": { + "brace-expansion": "^2.0.1" + }, + "engines": { + "node": ">=16 || 14 >=14.17" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, "node_modules/@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -12109,6 +12146,12 @@ "node": ">= 0.12.0" } }, + "node_modules/code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true + }, "node_modules/collapse-white-space": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", @@ -34545,6 +34588,16 @@ } } }, + "node_modules/ts-morph": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-25.0.1.tgz", + "integrity": "sha512-QJEiTdnz1YjrB3JFhd626gX4rKHDLSjSVMvGGG4v7ONc3RBwa0Eei98G9AT9uNFDMtV54JyuXsFeC+OH0n6bXQ==", + "dev": true, + "dependencies": { + "@ts-morph/common": "~0.26.0", + "code-block-writer": "^13.0.3" + } + }, "node_modules/ts-node": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", @@ -44261,6 +44314,37 @@ "resolved": "https://registry.npmjs.org/@trysound/sax/-/sax-0.2.0.tgz", "integrity": "sha512-L7z9BgrNEcYyUYtF+HaEfiS5ebkh9jXqbszz7pC0hRBPaatV0XjSD3+eHrpqFemQfgwiFF0QPIarnIihIDn7OA==" }, + "@ts-morph/common": { + "version": "0.26.1", + "resolved": "https://registry.npmjs.org/@ts-morph/common/-/common-0.26.1.tgz", + "integrity": "sha512-Sn28TGl/4cFpcM+jwsH1wLncYq3FtN/BIpem+HOygfBWPT5pAeS5dB4VFVzV8FbnOKHpDLZmvAl4AjPEev5idA==", + "dev": true, + "requires": { + "fast-glob": "^3.3.2", + "minimatch": "^9.0.4", + "path-browserify": "^1.0.1" + }, + "dependencies": { + "brace-expansion": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz", + "integrity": "sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==", + "dev": true, + "requires": { + "balanced-match": "^1.0.0" + } + }, + "minimatch": { + "version": "9.0.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-9.0.5.tgz", + "integrity": "sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==", + "dev": true, + "requires": { + "brace-expansion": "^2.0.1" + } + } + } + }, "@tsconfig/node10": { "version": "1.0.9", "resolved": "https://registry.npmjs.org/@tsconfig/node10/-/node10-1.0.9.tgz", @@ -46800,6 +46884,12 @@ "integrity": "sha512-QVb0dM5HvG+uaxitm8wONl7jltx8dqhfU33DcqtOZcLSVIKSDDLDi7+0LbAKiyI8hD9u42m2YxXSkMGWThaecQ==", "dev": true }, + "code-block-writer": { + "version": "13.0.3", + "resolved": "https://registry.npmjs.org/code-block-writer/-/code-block-writer-13.0.3.tgz", + "integrity": "sha512-Oofo0pq3IKnsFtuHqSF7TqBfr71aeyZDVJ0HpmqB7FBM2qEigL0iPONSCZSO9pE9dZTAxANe5XHG9Uy0YMv8cg==", + "dev": true + }, "collapse-white-space": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/collapse-white-space/-/collapse-white-space-2.1.0.tgz", @@ -62090,6 +62180,16 @@ "yargs-parser": "^21.0.1" } }, + "ts-morph": { + "version": "25.0.1", + "resolved": "https://registry.npmjs.org/ts-morph/-/ts-morph-25.0.1.tgz", + "integrity": "sha512-QJEiTdnz1YjrB3JFhd626gX4rKHDLSjSVMvGGG4v7ONc3RBwa0Eei98G9AT9uNFDMtV54JyuXsFeC+OH0n6bXQ==", + "dev": true, + "requires": { + "@ts-morph/common": "~0.26.0", + "code-block-writer": "^13.0.3" + } + }, "ts-node": { "version": "10.9.1", "resolved": "https://registry.npmjs.org/ts-node/-/ts-node-10.9.1.tgz", diff --git a/package.json b/package.json index 3fc5a17e5..46b1b8a36 100644 --- a/package.json +++ b/package.json @@ -25,6 +25,7 @@ "build-release": "npm run build-prod -- --release --codes", "build-www": "npm run clean && npm run build -- --www && npm run build -- --www --prod && npm run prepare-www", "build-types": "tsc -p ./tsconfig.build.json && node ./scripts/validate-tsc-types.js", + "lint-flow": "node ./scripts/lint-flow-types.js", "clean": "node scripts/clean.js", "extract-codes": "node scripts/build.js --codes", "flow": "node ./scripts/check-flow-types.js", @@ -163,6 +164,7 @@ "glob": "^10.4.1", "google-closure-compiler": "^20220202.0.0", "gzip-size": "^6.0.0", + "hermes-estree": "^0.26.0", "hermes-parser": "^0.26.0", "hermes-transform": "^0.26.0", "husky": "^7.0.1", @@ -181,6 +183,7 @@ "rollup": "^4.22.4", "tmp": "^0.2.1", "ts-jest": "^29.1.2", + "ts-morph": "^25.0.1", "ts-node": "^10.9.1", "typedoc": "^0.25.12", "typescript": "^5.4.5", diff --git a/scripts/lint-flow-types.js b/scripts/lint-flow-types.js new file mode 100644 index 000000000..eb6804a67 --- /dev/null +++ b/scripts/lint-flow-types.js @@ -0,0 +1,174 @@ +/** + * Copyright (c) Meta Platforms, Inc. and affiliates. + * + * This source code is licensed under the MIT license found in the + * LICENSE file in the root directory of this source tree. + * + */ +// @ts-check +'use strict'; + +const fs = require('fs-extra'); +const ts = require('typescript'); +const tsMorph = require('ts-morph'); +const hermesParser = require('hermes-parser'); +const {packagesManager} = require('./shared/packagesManager'); + +const pretty = process.env.CI !== 'true'; + +/** @type {ts.FormatDiagnosticsHost} */ +const diagnosticsHost = { + getCanonicalFileName: (fn) => fn, + getCurrentDirectory: () => './', + getNewLine: () => '\n', +}; + +/** + * Validate that the manually maintained .flow types have the same exports as + * the corresponding .d.ts types produced by the build. + * + * `process.exit(1)` on failure. + */ +function lintFlowTypes() { + let didError = false; + const project = new tsMorph.Project({tsConfigFilePath: './tsconfig.json'}); + for (const pkg of packagesManager.getPublicPackages()) { + didError = lintFlowTypesForPackage(project, pkg) || didError; + } + if (didError) { + process.exit(1); + } +} + +function collectFlowExports(flowAst) { + const exportNames = new Map(); + const exportId = (node) => { + const identifier = + node.type === 'Identifier' + ? node + : 'id' in node && node.id.type === 'Identifier' + ? node.id + : null; + if (identifier) { + exportNames.set(identifier.name, identifier); + return true; + } + return false; + }; + hermesParser.SimpleTraverser.traverse(flowAst, { + enter: (node, parent) => { + if ( + parent && + (parent.type === 'DeclareExportDeclaration' || + parent.type === 'ExportNamedDeclaration') + ) { + if (exportId(node)) { + // ok + } else if (node.type === 'VariableDeclaration') { + for (const declaration of node.declarations) { + if (!exportId(declaration)) { + // debugger; + } + } + } else if (node.type === 'ExportSpecifier') { + if (!exportId(node.exported)) { + // debugger; + } + } else { + // debugger; + } + } + }, + leave: () => {}, + }); + return exportNames; +} + +function compareFlowDts( + /** @type {PackageMetadata} */ pkg, + /** @type {string} */ flowFilePath, + /** @type {tsMorph.SourceFile} */ entrypoint, + /** @type {ts.Diagnostic[]} */ diagnostics, + /** @type {import('hermes-estree').Identifier[]} */ flowDiagnostics, +) { + const flowAst = hermesParser.parse(fs.readFileSync(flowFilePath, 'utf-8'), { + enableExperimentalComponentSyntax: true, + flow: 'all', + sourceFilename: flowFilePath, + sourceType: 'module', + }); + const flowMap = collectFlowExports(flowAst); + const symbols = entrypoint.getExportSymbols(); + const tsMap = new Map(symbols.map((sym) => [sym.getName(), sym])); + for (const [name, symbol] of tsMap) { + if (flowMap.has(name)) { + continue; + } + for (const decl of symbol.getDeclarations()) { + const start = decl.getStart(); + const end = decl.getEnd(); + diagnostics.push({ + category: ts.DiagnosticCategory.Warning, + code: Infinity, + file: entrypoint.compilerNode, + length: end - start, + messageText: `Missing flow export for TypeScript export '${name}'`, + start, + }); + break; + } + // debugger; + } + for (const [name, flowToken] of flowMap) { + if (tsMap.has(name)) { + continue; + } + flowDiagnostics.push(flowToken); + } +} + +function lintFlowTypesForPackage( + /** @type {tsMorph.Project} */ project, + /** @type {PackageMetadata} */ pkg, +) { + const def = pkg.getPackageBuildDefinition(); + if (def.packageName === 'lexical-eslint-plugin') { + return false; + } + /** @type {ts.Diagnostic[]} */ + const diagnostics = []; + /** @type {import('hermes-estree').Identifier[]} */ + const flowDiagnostics = []; + for (const {outputFileName, sourceFileName} of def.modules) { + const entrypoint = project.addSourceFileAtPath( + pkg.resolve('src', sourceFileName), + ); + const flowFilePath = pkg.resolve('flow', `${outputFileName}.js.flow`); + if (!fs.existsSync(flowFilePath)) { + console.error(`Missing ${flowFilePath}`); + process.exit(1); + } + compareFlowDts(pkg, flowFilePath, entrypoint, diagnostics, flowDiagnostics); + } + if (diagnostics.length > 0 || flowDiagnostics.length > 0) { + const msg = ( + pretty ? ts.formatDiagnosticsWithColorAndContext : ts.formatDiagnostics + )(diagnostics, diagnosticsHost); + if (msg) { + console.error(msg.replace(/ TSInfinity:/g, ':')); + } + const flowMsg = flowDiagnostics + .map( + (ident) => + `${ident.loc.source}:${ident.loc.start.line}:${ident.loc.start.column} - warning: Flow export '${ident.name}' does not have a TypeScript declaration`, + ) + .join('\n'); + if (flowMsg) { + console.error(flowMsg); + } + return true; + } + return false; +} + +lintFlowTypes();