diff --git a/src/analyze/report.ts b/src/analyze/report.ts index 37e631e..3bce3f8 100644 --- a/src/analyze/report.ts +++ b/src/analyze/report.ts @@ -5,6 +5,7 @@ import type {FileSystem} from '../file-system.js'; import type { Options, ReportPlugin, + ReportPhase, Stats, Message, AnalysisContext @@ -12,20 +13,48 @@ import type { import {runPublint} from './publint.js'; import {runReplacements} from './replacements.js'; import {runDependencyAnalysis} from './dependencies.js'; -import {runPlugins} from '../plugin-runner.js'; +import {runPlugin, runPlugins} from '../plugin-runner.js'; import {getPackageJson, detectLockfile} from '../utils/package-json.js'; import {parse as parseLockfile} from 'lockparse'; import {runDuplicateDependencyAnalysis} from './duplicate-dependencies.js'; import {runCoreJsAnalysis} from './core-js.js'; import {runWebFeaturesCodemodsAnalysis} from './web-features-codemods.js'; -const plugins: ReportPlugin[] = [ - runPublint, - runReplacements, - runDependencyAnalysis, - runDuplicateDependencyAnalysis, - runCoreJsAnalysis, - runWebFeaturesCodemodsAnalysis +export const ANALYSIS_PLUGINS: Array<{ + id: string; + title: string; + run: ReportPlugin; +}> = [ + { + id: 'publint', + title: 'Checking package publishing', + run: runPublint + }, + { + id: 'replacements', + title: 'Checking dependency replacements', + run: runReplacements + }, + { + id: 'dependencies', + title: 'Analyzing dependencies', + run: runDependencyAnalysis + }, + { + id: 'duplicate-dependencies', + title: 'Checking duplicate dependencies', + run: runDuplicateDependencyAnalysis + }, + { + id: 'core-js', + title: 'Scanning core-js usage', + run: runCoreJsAnalysis + }, + { + id: 'web-features', + title: 'Scanning source files', + run: runWebFeaturesCodemodsAnalysis + } ]; async function computeInfo(fileSystem: FileSystem) { @@ -41,7 +70,9 @@ async function computeInfo(fileSystem: FileSystem) { }; } -export async function report(options: Options) { +async function buildAnalysisContext( + options: Options +): Promise { const {root = process.cwd()} = options ?? {}; const messages: Message[] = []; @@ -93,7 +124,7 @@ export async function report(options: Options) { extraStats: [] }; - const context: AnalysisContext = { + return { fs: fileSystem, root, packageFile, @@ -102,9 +133,62 @@ export async function report(options: Options) { messages, options }; - await runPlugins(context, plugins); +} + +async function finalizeReport(context: AnalysisContext) { + const info = await computeInfo(context.fs); + + return {info, messages: context.messages, stats: context.stats}; +} + +async function runPhasedReport( + options: Options, + phased: NonNullable +) { + let context: AnalysisContext | undefined; + const seenExtra = new Set(); + + const phases: ReportPhase[] = [ + { + id: 'setup', + title: 'Loading project files', + run: async () => { + context = await buildAnalysisContext(options); + for (const stat of context.stats.extraStats ?? []) { + seenExtra.add(stat.name); + } + } + }, + ...ANALYSIS_PLUGINS.map(({id, title, run}) => ({ + id, + title, + run: async () => { + if (!context) { + throw new Error('Analysis context was not initialized.'); + } + await runPlugin(context, run, seenExtra); + } + })) + ]; + + await phased(phases); - const info = await computeInfo(fileSystem); + if (!context) { + throw new Error('Analysis context was not initialized.'); + } + + return finalizeReport(context); +} - return {info, messages, stats}; +export async function report(options: Options) { + if (options.phased) { + return runPhasedReport(options, options.phased); + } + + const context = await buildAnalysisContext(options); + await runPlugins( + context, + ANALYSIS_PLUGINS.map((plugin) => plugin.run) + ); + return finalizeReport(context); } diff --git a/src/commands/analyze-progress.ts b/src/commands/analyze-progress.ts new file mode 100644 index 0000000..fa96bf5 --- /dev/null +++ b/src/commands/analyze-progress.ts @@ -0,0 +1,31 @@ +import * as prompts from '@clack/prompts'; +import {styleText} from 'node:util'; +import type {PhasedRunner, ReportPhase} from '../types.js'; + +const COMPLETE_SYMBOL = styleText('green', '◈'); +const ERROR_SYMBOL = styleText('red', '◈'); + +function finishPhase( + spinner: ReturnType, + title: string, + symbol: string +) { + spinner.clear(); + prompts.log.message(title, {symbol, spacing: 0}); +} + +export function createAnalyzePhasedRunner(): PhasedRunner { + return async (phases: ReportPhase[]) => { + for (const {title, run} of phases) { + const s = prompts.spinner(); + s.start(title); + try { + await run(); + finishPhase(s, title, COMPLETE_SYMBOL); + } catch (err) { + finishPhase(s, title, ERROR_SYMBOL); + throw err; + } + } + }; +} diff --git a/src/commands/analyze.ts b/src/commands/analyze.ts index 09ad0f5..d90d7e6 100644 --- a/src/commands/analyze.ts +++ b/src/commands/analyze.ts @@ -7,6 +7,7 @@ import {report} from '../index.js'; import {enableDebug} from '../logger.js'; import {wrapAnsi} from 'fast-wrap-ansi'; import {parseCategories} from '../categories.js'; +import {createAnalyzePhasedRunner} from './analyze-progress.js'; import type {Message} from '../types.js'; function formatBytes(bytes: number) { @@ -107,7 +108,11 @@ export async function run(ctx: CommandContext) { root, manifest: customManifests, src: srcDirs, - categories: parsedCategories + categories: parsedCategories, + phased: + jsonOutput || !process.stdout.isTTY + ? undefined + : createAnalyzePhasedRunner() }); const thresholdRank = FAIL_THRESHOLD_RANK[logLevel] ?? 0; diff --git a/src/plugin-runner.ts b/src/plugin-runner.ts index 58deb00..bc5b93d 100644 --- a/src/plugin-runner.ts +++ b/src/plugin-runner.ts @@ -26,6 +26,20 @@ function updateStats( } } +export async function runPlugin( + context: AnalysisContext, + plugin: ReportPlugin, + seenExtra: Set +): Promise { + const res = await plugin(context); + + context.messages.push(...res.messages); + + if (res.stats) { + updateStats(context.stats, res.stats, seenExtra); + } +} + export async function runPlugins( context: AnalysisContext, plugins: ReportPlugin[] @@ -34,12 +48,6 @@ export async function runPlugins( const seenExtra = new Set(extraStats.map((s) => s.name)); for (const plugin of plugins) { - const res = await plugin(context); - - context.messages.push(...res.messages); - - if (res.stats) { - updateStats(context.stats, res.stats, seenExtra); - } + await runPlugin(context, plugin, seenExtra); } } diff --git a/src/test/report-phased.test.ts b/src/test/report-phased.test.ts new file mode 100644 index 0000000..3fe3cbd --- /dev/null +++ b/src/test/report-phased.test.ts @@ -0,0 +1,56 @@ +import {describe, it, expect, beforeAll, afterAll} from 'vitest'; +import {report, ANALYSIS_PLUGINS} from '../analyze/report.js'; +import {createTempDir, cleanupTempDir, createTestPackage} from './utils.js'; + +let tempDir: string; + +beforeAll(async () => { + tempDir = await createTempDir(); + await createTestPackage(tempDir, { + name: 'mock-package', + version: '1.0.0', + type: 'module', + main: 'index.js' + }); +}); + +afterAll(async () => { + await cleanupTempDir(tempDir); +}); + +describe('report phased execution', () => { + it('runs phases in order when phased runner is provided', async () => { + const executed: Array<{id: string; title: string}> = []; + + await report({ + root: tempDir, + phased: async (phases) => { + for (const phase of phases) { + executed.push({id: phase.id, title: phase.title}); + await phase.run(); + } + } + }); + + expect(executed).toEqual([ + {id: 'setup', title: 'Loading project files'}, + ...ANALYSIS_PLUGINS.map(({id, title}) => ({id, title})) + ]); + }); + + it('returns the same shape without a phased runner', async () => { + const result = await report({root: tempDir}); + + expect(result).toMatchObject({ + info: { + name: 'mock-package', + version: '1.0.0' + }, + stats: { + name: 'mock-package', + version: '1.0.0' + }, + messages: expect.any(Array) + }); + }); +}); diff --git a/src/types.ts b/src/types.ts index a39af0c..be36a6c 100644 --- a/src/types.ts +++ b/src/types.ts @@ -3,11 +3,20 @@ import type {Codemod, CodemodOptions} from 'module-replacements-codemods'; import type {ParsedLockFile} from 'lockparse'; import type {ParsedCategories} from './categories.js'; +export interface ReportPhase { + id: string; + title: string; + run: () => Promise; +} + +export type PhasedRunner = (phases: ReportPhase[]) => Promise; + export interface Options { root?: string; manifest?: string[]; src?: string[]; categories?: ParsedCategories; + phased?: PhasedRunner; } export interface StatLike {