diff --git a/flow-typed/microsoft-api-extractor.js b/flow-typed/microsoft-api-extractor.js index 9535dda310..e4ac6f93a1 100644 --- a/flow-typed/microsoft-api-extractor.js +++ b/flow-typed/microsoft-api-extractor.js @@ -44,7 +44,28 @@ declare module '@microsoft/api-extractor' { ... }; + declare export type ICompilerStateCreateOptions = { + // Additional `.d.ts` files to include in the program's root set, beyond + // the config's own `mainEntryPointFilePath`. + additionalEntryPoints?: Array, + typescriptCompilerFolder?: string, + ... + }; + + // A built TypeScript program. Reused across `Extractor.invoke` calls so the + // program is built once for all entry points rather than once per entry + // point; `invoke` only reads it, and builds its own if none is passed. + declare export class CompilerState { + static create( + config: ExtractorConfig, + options?: ICompilerStateCreateOptions, + ): CompilerState; + } + declare export type IExtractorInvokeOptions = { + // An existing program to extract from, instead of building a fresh one + // from the config's `tsconfigFilePath`. + compilerState?: CompilerState, // When true, update the API report on disk; when false (CI), leave it // untouched and flag any difference via `apiReportChanged`. localBuild?: boolean, diff --git a/scripts/generateApiSnapshots.js b/scripts/generateApiSnapshots.js index 2d5677a392..32dae7231d 100644 --- a/scripts/generateApiSnapshots.js +++ b/scripts/generateApiSnapshots.js @@ -11,65 +11,344 @@ 'use strict'; -import type {PackageResult} from './generateApiSnapshots.worker.js'; - import { AUTO_GENERATED_PATTERNS, + type Logger, generateTsDefsForJsGlobs, } from './generateTypeScriptDefinitions'; +import { + CompilerState, + Extractor, + ExtractorConfig, +} from '@microsoft/api-extractor'; import fs from 'node:fs'; import os from 'node:os'; import path from 'node:path'; -import {Worker} from 'node:worker_threads'; const WORKSPACE_ROOT = path.resolve(__dirname, '..'); const PACKAGES_DIR = path.join(WORKSPACE_ROOT, 'packages'); -const WORKER_PATH = path.join(__dirname, 'generateApiSnapshots.worker.js'); -// Run the per-package worker for one package on its own thread. API Extractor -// is synchronous and CPU-bound (it builds a TypeScript program per entry -// point), so threads — not promises alone — are what actually overlap the work. -function processPackageInWorker( +// Extraction-only tsconfig (adds the "dom" lib on top of the project config). +const TSCONFIG_PATH = path.join(WORKSPACE_ROOT, 'tsconfig.api-extractor.json'); + +// Committed public API snapshot file, written at each package's root. One +// report is generated per public entry point. +const SNAPSHOT_FILENAME = 'API.md'; + +// package.json exports subpaths that never describe a public, type-generated +// entry point and are therefore excluded from snapshotting. +const NON_API_EXPORT_KEYS = new Set(['./package.json']); + +// A package.json "exports" target: a module path, or a conditional map of +// them. Read straight from JSON, so the shape is only as trustworthy as the +// manifest — every consumer below re-checks it. +type ExportsTarget = + string | {readonly [condition: string]: ExportsTarget} | null; + +type PackageJson = Readonly<{ + name: string, + exports?: ExportsTarget, + ... +}>; + +// One public entry point of one package: the `.d.ts` API Extractor reads, and +// where its report ends up. +type EntryPoint = Readonly<{ + packageName: string, + packageDir: string, + packageJsonPath: string, + outputFileName: string, + tempReportFileName: string, + dtsPath: string, + exportKey: string, +}>; + +function readPackageJson(absolutePath: string): PackageJson { + return JSON.parse(fs.readFileSync(absolutePath, 'utf-8')); +} + +function unscopedName(packageName: string): string { + return packageName.replace(/^@[^/]+\//, ''); +} + +// Resolve the exports target for a subpath to a single module path string, +// unwrapping conditional exports (picking a plain "." / "default" / first). +function resolveExportTarget(value: ExportsTarget): ?string { + if (typeof value === 'string') { + return value; + } + if (value == null) { + return null; + } + const resolved = + value.import ?? value.default ?? value.require ?? Object.values(value)[0]; + return typeof resolved === 'string' ? resolved : null; +} + +// Whether a `.d.ts` declares any exports. API Extractor treats a declaration +// file with no exports as a non-module and errors out, so such entry points +// (e.g. CLI-only packages) are skipped rather than snapshotted. +function hasExports(dtsAbsolutePath: string): boolean { + const source = fs.readFileSync(dtsAbsolutePath, 'utf-8'); + return /^\s*export[\s{*=]/m.test(source); +} + +// Map a `./src/.js` exports target to its generated `types/.d.ts`. +function sourceTargetToDts(packageDir: string, target: string): ?string { + const match = /^\.\/src\/(.+)\.js$/.exec(target); + if (match == null) { + return null; + } + return path.join(packageDir, 'types', match[1] + '.d.ts'); +} + +// Committed snapshot file name for an entry point, written at the package +// root. The main entry is `API.md`; secondary subpaths are suffixed, e.g. +// `API-.md`. +function outputFileNameFor(exportKey: string): string { + if (exportKey === '.') { + return SNAPSHOT_FILENAME; + } + const suffix = exportKey.replace(/^\.\//, '').replace(/\//g, '-'); + return `API-${suffix}.md`; +} + +// Unique scratch report name handed to API Extractor, derived from the package +// name and exports subpath so all reports can share a single temp folder. +function tempReportFileNameFor(packageName: string, exportKey: string): string { + const base = unscopedName(packageName); + if (exportKey === '.') { + return `${base}.api.md`; + } + const suffix = exportKey.replace(/^\.\//, '').replace(/\//g, '-'); + return `${base}-${suffix}.api.md`; +} + +// Collect the public entry points for a package from its `exports` field. +// Wildcard, `./package.json`, and non-`./src/*.js` targets are excluded, as +// are entry points whose `.d.ts` has not been generated (e.g. runtime-only +// modules) — those are returned separately as `skipped`. +function collectEntryPoints( packageDir: string, + packageJsonPath: string, + packageJson: PackageJson, +): {entryPoints: Array, skipped: Array} { + const packageName = String(packageJson.name); + const exportsField = packageJson.exports; + const entryPoints: Array = []; + const skipped: Array = []; + + if ( + exportsField == null || + (typeof exportsField !== 'string' && typeof exportsField !== 'object') + ) { + return {entryPoints, skipped}; + } + + const entries = + typeof exportsField === 'string' + ? [['.', exportsField]] + : Object.entries(exportsField); + + for (const [exportKey, rawValue] of entries) { + if (NON_API_EXPORT_KEYS.has(exportKey) || exportKey.includes('*')) { + continue; + } + const target = resolveExportTarget(rawValue); + if (target == null) { + continue; + } + const dtsPath = sourceTargetToDts(packageDir, target); + if (dtsPath == null) { + // A public entry point whose target isn't a `./src/.js` module (e.g. + // `./src/foo.mjs`, `./lib/foo.js`, or a bare specifier), so we can't map + // it to a generated `.d.ts`. Record it so misconfigured exports surface + // in the summary rather than being silently dropped. + skipped.push( + `${packageName} "${exportKey}" (unsupported exports target "${target}")`, + ); + continue; + } + if (!fs.existsSync(dtsPath)) { + // A public entry point without generated types (e.g. metro-runtime's + // runtime-only modules). Record it so the summary can surface it. + skipped.push( + `${packageName} "${exportKey}" (no ${path.relative(WORKSPACE_ROOT, dtsPath)})`, + ); + continue; + } + if (!hasExports(dtsPath)) { + // A public entry point with no exported API surface (e.g. a CLI-only + // package). There is nothing to snapshot. + skipped.push(`${packageName} "${exportKey}" (no exported API surface)`); + continue; + } + entryPoints.push({ + packageName, + packageDir, + packageJsonPath, + outputFileName: outputFileNameFor(exportKey), + tempReportFileName: tempReportFileNameFor(packageName, exportKey), + dtsPath, + exportKey, + }); + } + + return {entryPoints, skipped}; +} + +// Post-process an API Extractor report into a committed snapshot. API Extractor +// tags every export with a release tag and flags the absence of TSDoc, but +// Metro's public surface is documented by its Flow types rather than TSDoc, so +// those annotations are pure noise. There is no config option to suppress them +// (they are hardcoded in API Extractor's ApiReportGenerator), so we strip them +// here: `(undocumented)` markers, the release-tag comment lines (`// @public` +// etc.), and the missing-`@packageDocumentation` notice, then collapse the +// blank lines left behind. +// +// We also blank out the line numbers in any flow-api-translator code frame, so +// that moving an offending declaration within its source file doesn't churn the +// snapshot. +function cleanReport(report: string): string { + const result = []; + for (const raw of report.split('\n')) { + const rawTrimmed = raw.trim(); + // Drop standalone `(undocumented)` markers and the missing-packageDoc + // notice entirely. (Checked before the inline strip below, which would + // otherwise reduce `// (undocumented)` to a bare `//`.) + if ( + rawTrimmed === '// (undocumented)' || + rawTrimmed === '// (No @packageDocumentation comment for this package)' + ) { + continue; + } + + // Normalize inline `(undocumented)` suffixes (e.g. `// @public + // (undocumented)` becomes `// @public`). + let line = raw.replace(/ \(undocumented\)/g, ''); + + // Strip the release-tag token from AEDoc comment lines, preserving any + // other modifiers (e.g. `// @public @deprecated` becomes `// @deprecated`). + const tagMatch = line.match( + /^(\s*)\/\/ @(?:public|beta|alpha|internal)\b ?(.*)$/, + ); + if (tagMatch != null) { + const rest = tagMatch[2].trim(); + if (rest === '') { + // Nothing but the release tag — drop the whole comment line. + continue; + } + line = `${tagMatch[1]}// ${rest}`; + } + + // flow-api-translator embeds a Babel code frame in the comment it emits for + // constructs it cannot translate (e.g. Flow's `empty`). Its gutter carries + // the line number in the *Flow source*, so any edit above that line — an + // internal-only change with no effect on the public API — would otherwise + // show up here. Blank the digits rather than removing them: the caret line + // beneath is padded to the same gutter width, so keeping the width keeps it + // aligned. + line = line.replace( + /^(\s*\*\s*>\s*)(\d+)(\s*\|)/, + (_, before, digits, after) => before + ' '.repeat(digits.length) + after, + ); + + // Collapse runs of blank lines left by the removals above. + if (line.trim() === '' && result[result.length - 1]?.trim() === '') { + continue; + } + result.push(line); + } + return result.join('\n'); +} + +// Build the API Extractor config for one entry point. The report is written to +// a scratch folder; the caller owns writing it to its committed location so we +// control the file name, path, and post-processing. +function prepareExtractorConfig( + entryPoint: EntryPoint, tempFolder: string, - verifyOnly: boolean, -): Promise { - return new Promise((resolve, reject) => { - const worker = new Worker(WORKER_PATH, { - workerData: { - packageDir, - tempFolder, - verifyOnly, - workspaceRoot: WORKSPACE_ROOT, +): ExtractorConfig { + return ExtractorConfig.prepare({ + configObject: { + projectFolder: entryPoint.packageDir, + mainEntryPointFilePath: entryPoint.dtsPath, + // Emit unix line endings to match repo conventions. + newlineKind: 'lf', + compiler: {tsconfigFilePath: TSCONFIG_PATH}, + apiReport: { + enabled: true, + reportFolder: tempFolder, + reportFileName: entryPoint.tempReportFileName, + reportTempFolder: tempFolder, }, - // Clear `-r @babel/register` - execArgv: [], - }); - let result: ?PackageResult = null; - worker.on('message', message => { - result = message; - }); - worker.on('error', reject); - worker.on('exit', code => { - if (code == 0 && result != null) { - resolve(result); - } else { - reject( - new Error( - `Worker for ${path.basename(packageDir)} exited with code ${code} ` + - (result == null ? 'without' : 'despite') + - ' reporting a result', - ), - ); + docModel: {enabled: false}, + dtsRollup: {enabled: false}, + tsdocMetadata: {enabled: false}, + // Keep snapshots focused on the API surface, not on lint-style advice. + messages: { + extractorMessageReporting: { + default: {logLevel: 'none', addToApiReportFile: false}, + }, + compilerMessageReporting: {default: {logLevel: 'none'}}, + tsdocMessageReporting: {default: {logLevel: 'none'}}, + }, + }, + configObjectFullPath: path.join( + entryPoint.packageDir, + 'api-extractor.json', + ), + packageJsonFullPath: entryPoint.packageJsonPath, + }); +} + +// Run API Extractor for a single entry point against an already-built program, +// and return its report text. +function runExtractor( + entryPoint: EntryPoint, + extractorConfig: ExtractorConfig, + compilerState: CompilerState, + tempFolder: string, +): {succeeded: boolean, report: string} { + // Always write to the scratch folder (localBuild); the caller diffs/commits + // the result itself. + const result = Extractor.invoke(extractorConfig, { + compilerState, + localBuild: true, + showVerboseMessages: false, + showDiagnostics: false, + // Silence API Extractor's per-invocation preamble, which is emitted once + // per entry point and says only which TypeScript version it bundles and + // that ours is newer. These are category 'console', which the `messages` + // config above cannot route (there is no `consoleMessageReporting` + // table), so they have to be discarded here. Suppress these two ids + // specifically rather than the whole category, which also carries + // genuine errors such as `console-api-report-folder-missing`. + messageCallback: message => { + if ( + message.messageId === 'console-preamble' || + message.messageId === 'console-compiler-version-notice' + ) { + message.logLevel = 'none'; } - }); + }, }); + + const report = fs.readFileSync( + path.join(tempFolder, entryPoint.tempReportFileName), + 'utf-8', + ); + + return {succeeded: result.succeeded, report}; } export async function generateApiSnapshots( - opts: Readonly<{verifyOnly: boolean}> = {verifyOnly: false}, + opts?: Readonly<{ + verifyOnly?: boolean, + logger?: Logger, + }>, ): Promise { - const {verifyOnly} = opts; + const {verifyOnly = false, logger} = opts ?? {}; // The `.d.ts` files that feed API Extractor are build artifacts and are not // checked in. Regenerate them from the current Flow sources (always in write @@ -78,6 +357,7 @@ export async function generateApiSnapshots( // output while a public-API change surfaces as a snapshot diff. await generateTsDefsForJsGlobs(AUTO_GENERATED_PATTERNS, { verifyOnly: false, + logger, // Underscore props are private APIs by Flow convention, so we don't want // them in public API.md snapshots as they're not semver guaranteed. We // do include them in .d.ts, as we document private APIs that way. @@ -98,58 +378,136 @@ export async function generateApiSnapshots( .map(name => path.join(PACKAGES_DIR, name)) .filter(dir => fs.lstatSync(dir).isDirectory()); - const verb = verifyOnly ? 'verified' : 'generated'; - const results = await Promise.all( - packageDirs.map(async packageDir => { - const startTime = process.hrtime.bigint(); - try { - const result = await processPackageInWorker( - packageDir, - tempFolder, - verifyOnly, - ); - // Report as each package lands, so slow packages are visible while the - // rest are still in flight. Output order is completion order, not the - // order of `packageDirs`. - if (result.generatedCount > 0) { - const elapsedMs = - Number(process.hrtime.bigint() - startTime) / 1_000_000; - process.stdout.write( - ` ${path.basename(packageDir)}: ${result.generatedCount} ` + - `snapshot(s) ${verb} in ${(elapsedMs / 1000).toFixed(1)}s\n`, + // Collect every public entry point before extracting anything: the shared + // program below has to be told about all of them up front. + const packages: Array<{ + packageDir: string, + entryPoints: Array, + }> = []; + for (const packageDir of packageDirs) { + const packageJsonPath = path.join(packageDir, 'package.json'); + if (!fs.existsSync(packageJsonPath)) { + continue; + } + const {entryPoints, skipped} = collectEntryPoints( + packageDir, + packageJsonPath, + readPackageJson(packageJsonPath), + ); + allSkipped.push(...skipped); + if (entryPoints.length > 0) { + packages.push({packageDir, entryPoints}); + } + } + + const allEntryPoints = packages.flatMap(({entryPoints}) => entryPoints); + + if (allEntryPoints.length > 0) { + // Extraction is CPU-bound and synchronous, but the cost is dominated by + // building the TypeScript program, not by extracting from it: every + // entry point's program has the same compiler options and the same root + // set (`include` in the extraction tsconfig, plus the entry point), and + // parses the whole `lib` and every `@types` package. `CompilerState` + // exists to be shared across `Extractor.invoke` calls, so we build one + // program covering every entry point and reuse it. That is cheaper in + // total than building one program per package, even spread across + // threads — so this all runs on the main thread, in package order. + // + // Which config the program is built from is immaterial (only its + // compiler settings are read), but every entry point has to be in the + // program's root set, hence `additionalEntryPoints`. + const programStartTime = performance.now(); + const compilerState = CompilerState.create( + prepareExtractorConfig(allEntryPoints[0], tempFolder), + {additionalEntryPoints: allEntryPoints.map(({dtsPath}) => dtsPath)}, + ); + logger?.log?.( + ` TypeScript program covering ${allEntryPoints.length} entry ` + + `point(s) built in ${((performance.now() - programStartTime) / 1000).toFixed(1)}s`, + ); + + const verb = verifyOnly ? 'verified' : 'generated'; + for (const {packageDir, entryPoints} of packages) { + const startTime = performance.now(); + let packageGeneratedCount = 0; + + for (const entryPoint of entryPoints) { + const context = `${entryPoint.packageName} (${entryPoint.exportKey})`; + try { + const {succeeded, report} = runExtractor( + entryPoint, + prepareExtractorConfig(entryPoint, tempFolder), + compilerState, + tempFolder, ); + if (!succeeded) { + errors.push({ + context, + error: new Error('API Extractor reported errors'), + }); + continue; + } + + const snapshot = cleanReport(report); + const outputPath = path.join( + entryPoint.packageDir, + entryPoint.outputFileName, + ); + + if (verifyOnly) { + let existing = null; + try { + existing = fs.readFileSync(outputPath, 'utf-8'); + } catch {} + if (existing !== snapshot) { + errors.push({ + context, + error: new Error( + `Public API snapshot ${entryPoint.outputFileName} is out ` + + 'of date. Run `js1 build metro-ts-defs` (internal) or ' + + '`yarn run build-api-snapshots` (OSS) to update it.', + ), + }); + } + // Count every processed entry point so the summary reflects the + // total number of snapshots verified, not just those already up + // to date. + packageGeneratedCount++; + } else { + fs.writeFileSync(outputPath, snapshot); + packageGeneratedCount++; + } + } catch (error) { + errors.push({context, error}); } - return result; - } catch (error) { - return { - errors: [{context: packageDir, error}], - skipped: [], - generatedCount: 0, - } as PackageResult; } - }), - ); - for (const result of results) { - errors.push(...result.errors); - allSkipped.push(...result.skipped); - generatedCount += result.generatedCount; + generatedCount += packageGeneratedCount; + // Now that the program is built once up front, this is the actual cost + // of extracting the package, rather than a per-package program build. + if (logger && packageGeneratedCount > 0) { + logger.log?.( + ` ${path.basename(packageDir)}: ${packageGeneratedCount} ` + + `snapshot(s) ${verb} in ${((performance.now() - startTime) / 1000).toFixed(1)}s`, + ); + } + } } } finally { fs.rmSync(tempFolder, {recursive: true, force: true}); } - if (allSkipped.length > 0) { - console.warn( + if (logger && allSkipped.length > 0) { + logger.warn( 'Skipped public entry points without generated TypeScript ' + `definitions:\n ${allSkipped.join('\n ')}`, ); } const verb = verifyOnly ? 'Verified' : 'Generated'; - process.stdout.write( + logger?.log( `${verb} ${generatedCount} public API snapshot(s) across Metro's OSS ` + - 'packages.\n', + 'packages.', ); if (errors.length > 0) { @@ -167,7 +525,7 @@ export async function generateApiSnapshots( // When run as a script, generate (or, with --verify, verify) all snapshots. if (require.main === module) { const verifyOnly = process.argv.includes('--verify'); - generateApiSnapshots({verifyOnly}).catch(error => { + generateApiSnapshots({verifyOnly, logger: console}).catch(error => { process.exitCode = 1; console.error(error); }); diff --git a/scripts/generateApiSnapshots.worker.js b/scripts/generateApiSnapshots.worker.js deleted file mode 100644 index 156c1a7462..0000000000 --- a/scripts/generateApiSnapshots.worker.js +++ /dev/null @@ -1,400 +0,0 @@ -/** - * 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. - * - * @flow strict-local - * @format - * @oncall react_native - */ - -// Worker module for `generateApiSnapshots.js`: generates (or verifies) every -// public API snapshot for a single package, on its own thread. - -const {Extractor, ExtractorConfig} = require('@microsoft/api-extractor'); -const fs = require('node:fs'); -const path = require('node:path'); -const {parentPort, workerData} = require('node:worker_threads'); - -/*:: -export type PackageResult = { - errors: Array<{context: string, error: Error}>, - skipped: Array, - generatedCount: number, -}; - -type EntryPoint = { - outputFileName: string, - tempReportFileName: string, - dtsPath: string, - exportKey: string, -}; -*/ - -// `generateApiSnapshots.js` always spawns this module as a worker; bail loudly -// if it is ever loaded some other way. -if (parentPort == null) { - throw new Error( - 'generateApiSnapshotsForPackage.mjs must be run as a worker thread', - ); -} -const {packageDir, tempFolder, verifyOnly, workspaceRoot} = workerData; - -// Extraction-only tsconfig (adds the "dom" lib on top of the project config). -// `workspaceRoot` is passed in rather than derived from this file's location, -// which as an ES module would need `import.meta.url`. -const TSCONFIG_PATH = path.join(workspaceRoot, 'tsconfig.api-extractor.json'); - -// Committed public API snapshot file, written at each package's root. One -// report is generated per public entry point. -const SNAPSHOT_FILENAME = 'API.md'; - -// package.json exports subpaths that never describe a public, type-generated -// entry point and are therefore excluded from snapshotting. -const NON_API_EXPORT_KEYS = new Set(['./package.json']); - -function readJson(absolutePath /*: string */) { - return JSON.parse(fs.readFileSync(absolutePath, 'utf-8')); -} - -function unscopedName(packageName /*: string */) { - return packageName.replace(/^@[^/]+\//, ''); -} - -// Resolve the exports target for a subpath to a single module path string, -// unwrapping conditional exports (picking a plain "." / "default" / first). -function resolveExportTarget(value /*: unknown */) { - if (typeof value === 'string') { - return value; - } - if (value == null || typeof value !== 'object') { - return null; - } - const resolved = - value.import ?? value.default ?? value.require ?? Object.values(value)[0]; - return typeof resolved === 'string' ? resolved : null; -} - -// Whether a `.d.ts` declares any exports. API Extractor treats a declaration -// file with no exports as a non-module and errors out, so such entry points -// (e.g. CLI-only packages) are skipped rather than snapshotted. -function hasExports(dtsAbsolutePath /*: string */) { - const source = fs.readFileSync(dtsAbsolutePath, 'utf-8'); - return /^\s*export[\s{*=]/m.test(source); -} - -// Map a `./src/.js` exports target to its generated `types/.d.ts`. -function sourceTargetToDts(packageDir /*: string */, target /*: string */) { - const match = /^\.\/src\/(.+)\.js$/.exec(target); - if (match == null) { - return null; - } - return path.join(packageDir, 'types', match[1] + '.d.ts'); -} - -// Committed snapshot file name for an entry point, written at the package -// root. The main entry is `API.md`; secondary subpaths are suffixed, e.g. -// `API-.md`. -function outputFileNameFor(exportKey /*: string */) { - if (exportKey === '.') { - return SNAPSHOT_FILENAME; - } - const suffix = exportKey.replace(/^\.\//, '').replace(/\//g, '-'); - return `API-${suffix}.md`; -} - -// Unique scratch report name handed to API Extractor, derived from the package -// name and exports subpath so all reports can share a single temp folder. -function tempReportFileNameFor( - packageName /*: string */, - exportKey /*: string */, -) { - const base = unscopedName(packageName); - if (exportKey === '.') { - return `${base}.api.md`; - } - const suffix = exportKey.replace(/^\.\//, '').replace(/\//g, '-'); - return `${base}-${suffix}.api.md`; -} - -// Collect the public entry points for a package from its `exports` field. -// Wildcard, `./package.json`, and non-`./src/*.js` targets are excluded, as -// are entry points whose `.d.ts` has not been generated (e.g. runtime-only -// modules) — those are returned separately as `skipped`. -function collectEntryPoints( - packageDir /*: string */, - packageJson /*: Readonly<{name: string, exports: unknown, ...}> */, -) /*: {entryPoints: Array, skipped: Array} */ { - const packageName = String(packageJson.name); - const exportsField = packageJson.exports; - const entryPoints = []; - const skipped = []; - - if ( - exportsField == null || - (typeof exportsField !== 'string' && typeof exportsField !== 'object') - ) { - return {entryPoints, skipped}; - } - - const entries = - typeof exportsField === 'string' - ? [['.', exportsField]] - : Object.entries(exportsField); - - for (const [exportKey, rawValue] of entries) { - if (NON_API_EXPORT_KEYS.has(exportKey) || exportKey.includes('*')) { - continue; - } - const target = resolveExportTarget(rawValue); - if (target == null) { - continue; - } - const dtsPath = sourceTargetToDts(packageDir, target); - if (dtsPath == null) { - // A public entry point whose target isn't a `./src/.js` module (e.g. - // `./src/foo.mjs`, `./lib/foo.js`, or a bare specifier), so we can't map - // it to a generated `.d.ts`. Record it so misconfigured exports surface - // in the summary rather than being silently dropped. - skipped.push( - `${packageName} "${exportKey}" (unsupported exports target "${target}")`, - ); - continue; - } - if (!fs.existsSync(dtsPath)) { - // A public entry point without generated types (e.g. metro-runtime's - // runtime-only modules). Record it so the summary can surface it. - skipped.push( - `${packageName} "${exportKey}" (no ${path.relative(workspaceRoot, dtsPath)})`, - ); - continue; - } - if (!hasExports(dtsPath)) { - // A public entry point with no exported API surface (e.g. a CLI-only - // package). There is nothing to snapshot. - skipped.push(`${packageName} "${exportKey}" (no exported API surface)`); - continue; - } - entryPoints.push({ - outputFileName: outputFileNameFor(exportKey), - tempReportFileName: tempReportFileNameFor(packageName, exportKey), - dtsPath, - exportKey, - }); - } - - return {entryPoints, skipped}; -} - -// Post-process an API Extractor report into a committed snapshot. API Extractor -// tags every export with a release tag and flags the absence of TSDoc, but -// Metro's public surface is documented by its Flow types rather than TSDoc, so -// those annotations are pure noise. There is no config option to suppress them -// (they are hardcoded in API Extractor's ApiReportGenerator), so we strip them -// here: `(undocumented)` markers, the release-tag comment lines (`// @public` -// etc.), and the missing-`@packageDocumentation` notice, then collapse the -// blank lines left behind. -// -// We also blank out the line numbers in any flow-api-translator code frame, so -// that moving an offending declaration within its source file doesn't churn the -// snapshot. -function cleanReport(report /*: string */) { - const result = []; - for (const raw of report.split('\n')) { - const rawTrimmed = raw.trim(); - // Drop standalone `(undocumented)` markers and the missing-packageDoc - // notice entirely. (Checked before the inline strip below, which would - // otherwise reduce `// (undocumented)` to a bare `//`.) - if ( - rawTrimmed === '// (undocumented)' || - rawTrimmed === '// (No @packageDocumentation comment for this package)' - ) { - continue; - } - - // Normalize inline `(undocumented)` suffixes (e.g. `// @public - // (undocumented)` becomes `// @public`). - let line = raw.replace(/ \(undocumented\)/g, ''); - - // Strip the release-tag token from AEDoc comment lines, preserving any - // other modifiers (e.g. `// @public @deprecated` becomes `// @deprecated`). - const tagMatch = line.match( - /^(\s*)\/\/ @(?:public|beta|alpha|internal)\b ?(.*)$/, - ); - if (tagMatch != null) { - const rest = tagMatch[2].trim(); - if (rest === '') { - // Nothing but the release tag — drop the whole comment line. - continue; - } - line = `${tagMatch[1]}// ${rest}`; - } - - // flow-api-translator embeds a Babel code frame in the comment it emits for - // constructs it cannot translate (e.g. Flow's `empty`). Its gutter carries - // the line number in the *Flow source*, so any edit above that line — an - // internal-only change with no effect on the public API — would otherwise - // show up here. Blank the digits rather than removing them: the caret line - // beneath is padded to the same gutter width, so keeping the width keeps it - // aligned. - line = line.replace( - /^(\s*\*\s*>\s*)(\d+)(\s*\|)/, - (_, before, digits, after) => before + ' '.repeat(digits.length) + after, - ); - - // Collapse runs of blank lines left by the removals above. - if (line.trim() === '' && result[result.length - 1]?.trim() === '') { - continue; - } - result.push(line); - } - return result.join('\n'); -} - -// Run API Extractor for a single entry point and return its report text. The -// report is written to (and read back from) a scratch folder; the caller owns -// writing it to its committed location so we control the file name, path, and -// post-processing. -function runExtractor( - packageDir /*: string */, - packageJsonPath /*: string */, - entryPoint /*: Readonly */, - tempFolder /*: string */, -) { - const configObject = { - projectFolder: packageDir, - mainEntryPointFilePath: entryPoint.dtsPath, - // Emit unix line endings to match repo conventions. - newlineKind: 'lf', - compiler: {tsconfigFilePath: TSCONFIG_PATH}, - apiReport: { - enabled: true, - reportFolder: tempFolder, - reportFileName: entryPoint.tempReportFileName, - reportTempFolder: tempFolder, - }, - docModel: {enabled: false}, - dtsRollup: {enabled: false}, - tsdocMetadata: {enabled: false}, - // Keep snapshots focused on the API surface, not on lint-style advice. - messages: { - extractorMessageReporting: { - default: {logLevel: 'none', addToApiReportFile: false}, - }, - compilerMessageReporting: {default: {logLevel: 'none'}}, - tsdocMessageReporting: {default: {logLevel: 'none'}}, - }, - }; - - const extractorConfig = ExtractorConfig.prepare({ - configObject, - configObjectFullPath: path.join(packageDir, 'api-extractor.json'), - packageJsonFullPath: packageJsonPath, - }); - - // Always write to the scratch folder (localBuild); the caller diffs/commits - // the result itself. - const result = Extractor.invoke(extractorConfig, { - localBuild: true, - showVerboseMessages: false, - showDiagnostics: false, - // Silence API Extractor's per-invocation preamble, which is emitted once - // per entry point and says only which TypeScript version it bundles and - // that ours is newer. These are category 'console', which the `messages` - // config above cannot route (there is no `consoleMessageReporting` - // table), so they have to be discarded here. Suppress these two ids - // specifically rather than the whole category, which also carries - // genuine errors such as `console-api-report-folder-missing`. - messageCallback: message => { - if ( - message.messageId === 'console-preamble' || - message.messageId === 'console-compiler-version-notice' - ) { - message.logLevel = 'none'; - } - }, - }); - - const report = fs.readFileSync( - path.join(tempFolder, entryPoint.tempReportFileName), - 'utf-8', - ); - - return {succeeded: result.succeeded, report}; -} - -// Generate (or verify) every snapshot for a single package. Pure with respect -// to other packages — each writes only its own `API*.md` files and its own -// uniquely-named scratch reports — so packages can run concurrently in -// separate worker threads. Returns a `PackageResult` (see the `.flow` sidecar). -function processPackage( - packageDir /*: string */, - tempFolder /*: string */, - verifyOnly /*: boolean */, -) /*: PackageResult */ { - const errors = []; - const packageJsonPath = path.join(packageDir, 'package.json'); - if (!fs.existsSync(packageJsonPath)) { - return {errors, skipped: [], generatedCount: 0}; - } - const packageJson = readJson(packageJsonPath); - const {entryPoints, skipped} = collectEntryPoints(packageDir, packageJson); - let generatedCount = 0; - - for (const entryPoint of entryPoints) { - const context = `${String(packageJson.name)} (${entryPoint.exportKey})`; - try { - const {succeeded, report} = runExtractor( - packageDir, - packageJsonPath, - entryPoint, - tempFolder, - ); - if (!succeeded) { - errors.push({ - context, - error: new Error('API Extractor reported errors'), - }); - continue; - } - - const snapshot = cleanReport(report); - const outputPath = path.join(packageDir, entryPoint.outputFileName); - - if (verifyOnly) { - let existing = null; - try { - existing = fs.readFileSync(outputPath, 'utf-8'); - } catch {} - if (existing !== snapshot) { - errors.push({ - context, - error: new Error( - `Public API snapshot ${entryPoint.outputFileName} is out ` + - 'of date. Run `js1 build metro-ts-defs` (internal) or ' + - '`yarn run build-api-snapshots` (OSS) to update it.', - ), - }); - } - // Count every processed entry point so the summary reflects the - // total number of snapshots verified, not just those already up to - // date. - generatedCount++; - } else { - fs.writeFileSync(outputPath, snapshot); - generatedCount++; - } - } catch (error) { - errors.push({context, error}); - } - } - - return {errors, skipped, generatedCount}; -} - -module.exports = { - processPackage, -}; - -parentPort.postMessage(processPackage(packageDir, tempFolder, verifyOnly)); diff --git a/scripts/generateTypeScriptDefinitions.js b/scripts/generateTypeScriptDefinitions.js index 58d978c05f..97bd053497 100644 --- a/scripts/generateTypeScriptDefinitions.js +++ b/scripts/generateTypeScriptDefinitions.js @@ -34,6 +34,13 @@ type LintMessage = { ... }; +type LogFn = (typeof console)['log']; +export interface Logger { + readonly log: LogFn; + readonly warn: LogFn; + readonly error: LogFn; +} + export const AUTO_GENERATED_PATTERNS: ReadonlyArray = ['packages/**']; // Globs of paths for which we do not generate TypeScript definitions, @@ -64,14 +71,32 @@ function isExistingTSDeclaration(filePath: string): boolean { export async function generateTsDefsForJsGlobs( globPattern: string | ReadonlyArray, - opts: Readonly<{ - verifyOnly: boolean, - mungeUnderscores: boolean, - }> = {verifyOnly: false, mungeUnderscores: false}, + opts?: Readonly<{ + verifyOnly?: boolean, + logger?: Logger, + mungeUnderscores?: boolean, + }>, ) { + const {verifyOnly = false, logger, mungeUnderscores = false} = opts ?? {}; const linter = new ESLint({ fix: true, cwd: WORKSPACE_ROOT, + overrideConfig: { + parserOptions: { + // typescript-eslint writes its "version of TypeScript which is not + // officially supported" warning straight to `console.log`, bypassing + // `logger`. Metro tracks TypeScript ahead of the range typescript-eslint + // declares, so this fires on every run. Route it through the caller's + // logger, and disable it outright for callers that pass none — notably + // the Jest test, where it is pure noise in the report. + // + // Note that passing a function also lifts typescript-eslint's own + // `process.stdout.isTTY` gate on the message, so a piped or redirected + // run now reports it where it used to be dropped. + loggerFn: + logger != null ? (message: string) => logger.warn(message) : false, + }, + }, }); const prettierConfig = await resolvePrettierConfig(); @@ -148,8 +173,8 @@ export async function generateTsDefsForJsGlobs( lintedOutput = lintResult.output ?? withoutUnusedGeneratedDeclarations; } - if (lintResult.messages.length > 0) { - console.warn(sourceFile, lintResult.messages); + if (logger && lintResult.messages.length > 0) { + logger.warn(sourceFile, lintResult.messages); } const formattedOutput = await prettier.format(lintedOutput, prettierConfig); @@ -174,7 +199,7 @@ export async function generateTsDefsForJsGlobs( existingDefs.delete(absoluteTsFile); - if (opts.verifyOnly) { + if (verifyOnly) { let existingFile = null; try { existingFile = await fs.promises.readFile(absoluteTsFile, 'utf-8'); @@ -223,7 +248,7 @@ export async function generateTsDefsForJsGlobs( const flowDef = await translateFlowToFlowDef( source, {}, - {mungeUnderscores: opts.mungeUnderscores}, + {mungeUnderscores}, ); if (flowDef.includes('declare module.exports')) { errors.push({ @@ -251,7 +276,7 @@ export async function generateTsDefsForJsGlobs( if (existingDefs.size > 0) { const orphanedDefs = Array.from(existingDefs); - if (opts.verifyOnly) { + if (verifyOnly) { orphanedDefs.forEach(sourceFile => { errors.push({ error: new Error('.d.ts appears to be orphaned'), @@ -420,6 +445,7 @@ if (process.mainModule === module) { // Omit globs to use hardcoded defaults. generateTsDefsForJsGlobs( process.argv.length >= 3 ? process.argv.slice(2) : AUTO_GENERATED_PATTERNS, + {logger: console}, ).catch(error => { process.exitCode = 1; console.error(error); diff --git a/tsconfig.api-extractor.json b/tsconfig.api-extractor.json index 25413e3258..eec672ac0c 100644 --- a/tsconfig.api-extractor.json +++ b/tsconfig.api-extractor.json @@ -6,6 +6,9 @@ // standard lib. This does not affect `tsc`/`flow` typechecking. "extends": "./tsconfig.json", "compilerOptions": { - "lib": ["es2023", "dom"] + "lib": ["es2023", "dom"], + // api-extractor doesn't need a clean type check. yarn run typecheck-ts + // handles that. + "skipLibCheck": true } }