Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 97 additions & 13 deletions src/analyze/report.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,27 +5,56 @@ import type {FileSystem} from '../file-system.js';
import type {
Options,
ReportPlugin,
ReportPhase,
Stats,
Message,
AnalysisContext
} from '../types.js';
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) {
Expand All @@ -41,7 +70,9 @@ async function computeInfo(fileSystem: FileSystem) {
};
}

export async function report(options: Options) {
async function buildAnalysisContext(
options: Options
): Promise<AnalysisContext> {
const {root = process.cwd()} = options ?? {};

const messages: Message[] = [];
Expand Down Expand Up @@ -93,7 +124,7 @@ export async function report(options: Options) {
extraStats: []
};

const context: AnalysisContext = {
return {
fs: fileSystem,
root,
packageFile,
Expand All @@ -102,9 +133,62 @@ export async function report(options: Options) {
messages,
options
};
await runPlugins(context, plugins);
}

async function finalizeReport(context: AnalysisContext) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you're not gaining much by extracting this. the function definition and call sites are about as many LoC as these two statements

const info = await computeInfo(context.fs);

return {info, messages: context.messages, stats: context.stats};
}

async function runPhasedReport(
options: Options,
phased: NonNullable<Options['phased']>
) {
let context: AnalysisContext | undefined;
const seenExtra = new Set<string>();

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);
}
31 changes: 31 additions & 0 deletions src/commands/analyze-progress.ts
Original file line number Diff line number Diff line change
@@ -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<typeof prompts.spinner>,
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;
}
}
};
}
7 changes: 6 additions & 1 deletion src/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -107,7 +108,11 @@ export async function run(ctx: CommandContext<typeof meta>) {
root,
manifest: customManifests,
src: srcDirs,
categories: parsedCategories
categories: parsedCategories,
phased:
jsonOutput || !process.stdout.isTTY
? undefined
: createAnalyzePhasedRunner()
});

const thresholdRank = FAIL_THRESHOLD_RANK[logLevel] ?? 0;
Expand Down
22 changes: 15 additions & 7 deletions src/plugin-runner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,20 @@ function updateStats(
}
}

export async function runPlugin(
context: AnalysisContext,
plugin: ReportPlugin,
seenExtra: Set<string>
): Promise<void> {
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[]
Expand All @@ -34,12 +48,6 @@ export async function runPlugins(
const seenExtra = new Set<string>(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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nothing changed here right? other than it being extracted into a single-use function

probably unnecessary?

@dreyfus92 dreyfus92 Jun 29, 2026

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

my initial thought was to encapsulate for readability purposes, but i think that you're right. it might be unnecessary. gonna fix this

}
}
56 changes: 56 additions & 0 deletions src/test/report-phased.test.ts
Original file line number Diff line number Diff line change
@@ -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)
});
});
});
9 changes: 9 additions & 0 deletions src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<void>;
}

export type PhasedRunner = (phases: ReportPhase[]) => Promise<void>;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think this is whats not sitting right with me. it seems over-engineered that we need a concept of a "phased runner", phases, etc.

i would expect something much, much simpler:

options.interactive; // boolean, from Options

// elsewhere...

const trackProgress = (options, fn) => {
  if (!options.interactive) {
    return fn();
  }
  startSpinner();
  const result = await fn();
  stopSpinner();
  return result;
};

// elsewhere again...

await trackProgress(options, async () => {
  // do some stuff
});


export interface Options {
root?: string;
manifest?: string[];
src?: string[];
categories?: ParsedCategories;
phased?: PhasedRunner;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is leaking internal implementation detail into the public options.

tbh im not sure we need a concept of a "runner" at all. that part may be overengineered. ill have a think and come back to it

}

export interface StatLike<T> {
Expand Down
Loading