[*] Feature: Add linter to check that flow types are consistent with typescript types (#7230)

This commit is contained in:
Bob Ippolito
2025-02-23 07:32:00 -08:00
committed by GitHub
parent 90bc029710
commit 1458aeec9d
3 changed files with 277 additions and 0 deletions

100
package-lock.json generated
View File

@ -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",

View File

@ -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",

174
scripts/lint-flow-types.js Normal file
View File

@ -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();