Skip to content
Merged
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
190 changes: 35 additions & 155 deletions src/analyze-dependencies.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,11 @@
import fs from 'node:fs/promises';
import fsSync from 'node:fs';
import path from 'node:path';
import {unpack} from '@publint/pack';
import {analyzePackageModuleType} from './compute-type.js';
import {pino} from 'pino';
import type {DependencyStats, DependencyAnalyzer} from './types.js';
import {fdir} from 'fdir';
import type {
DependencyStats,
DependencyAnalyzer,
PackageJsonLike
} from './types.js';
import {FileSystem} from './file-system.js';

// Create a logger instance with pretty printing for development
const logger = pino({
Expand Down Expand Up @@ -36,151 +36,30 @@ const logger = pino({
// Re-export types
export type {DependencyStats, DependencyAnalyzer};

export class LocalDependencyAnalyzer implements DependencyAnalyzer {
async analyzeDependencies(root: string): Promise<DependencyStats> {
try {
const pkgJsonPath = path.join(root, 'package.json');
logger.debug('Reading package.json from:', pkgJsonPath);

const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf-8'));

// Count direct dependencies
const directDependencies = Object.keys(pkgJson.dependencies || {}).length;
const devDependencies = Object.keys(pkgJson.devDependencies || {}).length;

logger.debug('Direct dependencies:', directDependencies);
logger.debug('Dev dependencies:', devDependencies);

// Analyze node_modules
let cjsDependencies = 0;
let esmDependencies = 0;
let installSize = 0;

// Walk through node_modules
const nodeModulesPath = path.join(root, 'node_modules');

try {
await fs.access(nodeModulesPath);
logger.debug('Found node_modules directory');

await this.walkNodeModules(nodeModulesPath, {
onPackage: (pkgJson) => {
const type = analyzePackageModuleType(pkgJson);
logger.debug(
`Package ${pkgJson.name}: ${type} (type=${pkgJson.type}, main=${pkgJson.main}, exports=${JSON.stringify(pkgJson.exports)})`
);

if (type === 'cjs') cjsDependencies++;
if (type === 'esm') esmDependencies++;
if (type === 'dual') {
cjsDependencies++;
esmDependencies++;
}
},
onFile: (filePath) => {
try {
const stats = fsSync.statSync(filePath);
installSize += stats.size;
} catch {
logger.debug('Error getting file stats for:', filePath);
}
}
});
} catch {
logger.debug('No node_modules directory found');
}

logger.debug('Analysis complete:');
logger.debug('- CJS dependencies:', cjsDependencies);
logger.debug('- ESM dependencies:', esmDependencies);
logger.debug('- Install size:', installSize, 'bytes');

return {
totalDependencies: directDependencies + devDependencies,
directDependencies,
devDependencies,
cjsDependencies,
esmDependencies,
installSize,
packageName: pkgJson.name,
version: pkgJson.version
};
} catch (error) {
logger.error('Error analyzing dependencies:', error);
throw error;
}
}

private async walkNodeModules(
dir: string,
callbacks: {
onPackage: (pkgJson: any) => void;
onFile: (filePath: string) => void;
},
seenPackages = new Set<string>()
) {
try {
const crawler = new fdir().withFullPaths().withSymlinks().crawl(dir);

const files = await crawler.withPromise();

for (const filePath of files) {
// Handle package.json files
if (filePath.endsWith('/package.json')) {
try {
const pkgJson = JSON.parse(await fs.readFile(filePath, 'utf-8'));
if (!seenPackages.has(pkgJson.name)) {
seenPackages.add(pkgJson.name);
logger.debug('Detected package:', pkgJson.name, 'at', filePath);
callbacks.onPackage(pkgJson);
} else {
logger.debug(
'Already seen package:',
pkgJson.name,
'at',
filePath
);
}
} catch {
logger.debug('Error reading package.json:', filePath);
}
} else {
// Handle regular files
callbacks.onFile(filePath);
}
}
} catch (error) {
logger.debug('Error walking directory:', dir, error);
}
}
}

// Keep the existing tarball analysis for backward compatibility
export async function analyzeDependencies(
tarball: ArrayBuffer
fileSystem: FileSystem
): Promise<DependencyStats> {
const {files, rootDir} = await unpack(tarball);
const decoder = new TextDecoder();
const packageFiles = await fileSystem.listPackageFiles();
const rootDir = await fileSystem.getRootDir();

// Debug: Log all files in the tarball
logger.debug('Files in tarball:');
for (const file of files) {
logger.debug(`- ${file.name}`);
logger.debug('Package.json files:');
for (const file of packageFiles) {
logger.debug(`- ${file}`);
}

// Find package.json
const pkgJson = files.find((f) => f.name === rootDir + '/package.json');
if (!pkgJson) {
throw new Error('No package.json found in the tarball.');
}
let pkg: PackageJsonLike;

const pkg = JSON.parse(decoder.decode(pkgJson.data));
try {
pkg = JSON.parse(await fileSystem.readFile(rootDir + '/package.json'));
} catch {
throw new Error('No package.json found.');
}

// Calculate total size
const installSize = files.reduce(
(acc, file) => acc + file.data.byteLength,
0
);
const installSize = await fileSystem.getInstallSize();

// Count dependencies
const directDependencies = Object.keys(pkg.dependencies || {}).length;
Expand All @@ -191,20 +70,22 @@ export async function analyzeDependencies(
let esmDependencies = 0;

// Analyze each dependency
for (const file of files) {
if (file.name.endsWith('/package.json')) {
try {
const depPkg = JSON.parse(decoder.decode(file.data));
const type = analyzePackageModuleType(depPkg);
if (type === 'cjs') cjsDependencies++;
if (type === 'esm') esmDependencies++;
if (type === 'dual') {
cjsDependencies++;
esmDependencies++;
}
} catch {
// Skip invalid package.json files
for (const file of packageFiles) {
if (file === rootDir + '/package.json') {
continue;
}

try {
const depPkg = JSON.parse(await fileSystem.readFile(file));
const type = analyzePackageModuleType(depPkg);
if (type === 'cjs') cjsDependencies++;
if (type === 'esm') esmDependencies++;
if (type === 'dual') {
cjsDependencies++;
esmDependencies++;
}
} catch {
// Skip invalid package.json files
}
}

Expand All @@ -216,7 +97,6 @@ export async function analyzeDependencies(
esmDependencies,
installSize,
packageName: pkg.name,
version: pkg.version,
tarballFiles: files.map((f) => f.name)
version: pkg.version
};
}
11 changes: 9 additions & 2 deletions src/commands/analyze.meta.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,16 @@ export const meta = {
args: {
pack: {
type: 'enum',
choices: ['auto', 'npm', 'yarn', 'pnpm', 'bun'] satisfies PackType[],
choices: [
'auto',
'npm',
'yarn',
'pnpm',
'bun',
'none'
] satisfies PackType[],
default: 'auto',
description: `Package manager to use for packing ('auto' | 'npm' | 'yarn' | 'pnpm' | 'bun')`
description: `Package manager to use for packing`
},
'log-level': {
type: 'enum',
Expand Down
83 changes: 11 additions & 72 deletions src/commands/analyze.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,7 +6,6 @@ import c from 'picocolors';
import {meta} from './analyze.meta.js';
import {report} from '../index.js';
import type {PackType} from '../types.js';
import {LocalDependencyAnalyzer} from '../analyze-dependencies.js';

const allowedPackTypes: PackType[] = ['auto', 'npm', 'yarn', 'pnpm', 'bun'];
const logger = pino({
Expand Down Expand Up @@ -41,7 +40,7 @@ export async function run(ctx: CommandContext<typeof meta.args>) {
// Set the logger level based on the option
logger.level = logLevel;

prompts.intro('Generating report...');
prompts.intro('Analyzing...');

if (typeof pack === 'string' && !allowedPackTypes.includes(pack)) {
prompts.cancel(
Expand All @@ -51,14 +50,12 @@ export async function run(ctx: CommandContext<typeof meta.args>) {
}

// If a path is passed, it must be a tarball file
let isTarball = false;
if (root) {
try {
const stat = await fs.stat(root);
if (stat.isFile()) {
const buffer = await fs.readFile(root);
pack = {tarball: buffer.buffer};
isTarball = true;
} else {
// Not a file, exit
prompts.cancel(
Expand All @@ -74,68 +71,10 @@ export async function run(ctx: CommandContext<typeof meta.args>) {
}
}

// Only run local analysis if the root is not a tarball file
if (!isTarball) {
const resolvedRoot = root || process.cwd();
const localAnalyzer = new LocalDependencyAnalyzer();
const localStats = await localAnalyzer.analyzeDependencies(resolvedRoot);

prompts.log.info('Local Analysis');
prompts.log.message(
`${c.cyan('Total deps ')} ${localStats.totalDependencies}`,
{spacing: 0}
);
prompts.log.message(
`${c.cyan('Direct deps ')} ${localStats.directDependencies}`,
{spacing: 0}
);
prompts.log.message(
`${c.cyan('Dev deps ')} ${localStats.devDependencies}`,
{spacing: 0}
);
prompts.log.message(
`${c.cyan('CJS deps ')} ${localStats.cjsDependencies}`,
{spacing: 0}
);
prompts.log.message(
`${c.cyan('ESM deps ')} ${localStats.esmDependencies}`,
{spacing: 0}
);
prompts.log.message(
`${c.cyan('Install size ')} ${formatBytes(localStats.installSize)}`,
{spacing: 0}
);
prompts.log.message(
c.yellowBright(
'Dependency type analysis is based on your installed node_modules.'
),
{spacing: 1}
);
prompts.log.message('', {spacing: 0});

// Display package info
prompts.log.info('Package info');
prompts.log.message(`${c.cyan('Name ')} ${localStats.packageName}`, {
spacing: 0
});
prompts.log.message(`${c.cyan('Version')} ${localStats.version}`, {
spacing: 0
});
prompts.log.message('', {spacing: 0});
}

// Then analyze the tarball
const {dependencies, messages} = await report({root, pack});

// Show files in tarball as debug output
if (Array.isArray(dependencies.tarballFiles)) {
logger.debug('Files in tarball:');
for (const file of dependencies.tarballFiles) {
logger.debug(` - ${file}`);
}
}

prompts.log.info('Tarball Analysis');
prompts.log.info('Summary');
prompts.log.message(
`${c.cyan('Total deps ')} ${dependencies.totalDependencies}`,
{spacing: 0}
Expand All @@ -148,20 +87,20 @@ export async function run(ctx: CommandContext<typeof meta.args>) {
`${c.cyan('Dev deps ')} ${dependencies.devDependencies}`,
{spacing: 0}
);
prompts.log.message(`${c.cyan('CJS deps ')} N/A`, {spacing: 0});
prompts.log.message(`${c.cyan('ESM deps ')} N/A`, {spacing: 0});
prompts.log.message(
`${c.cyan('Install size ')} ${formatBytes(dependencies.installSize)}`,
`${c.cyan('CJS deps ')} ${dependencies.cjsDependencies}`,
{spacing: 0}
);
prompts.log.message(
c.yellowBright(
'Dependency type analysis is only available for local analysis, as tarballs do not include dependencies.'
),
{spacing: 1}
`${c.cyan('ESM deps ')} ${dependencies.esmDependencies}`,
{spacing: 0}
);
prompts.log.message(
`${c.cyan('Install size ')} ${formatBytes(dependencies.installSize)}`,
{spacing: 0}
);

prompts.log.info('Package report');
prompts.log.info('Results:');
prompts.log.message('', {spacing: 0});
// Display tool analysis results
if (messages.length > 0) {
Expand Down Expand Up @@ -198,5 +137,5 @@ export async function run(ctx: CommandContext<typeof meta.args>) {
prompts.log.message('', {spacing: 0});
}
}
prompts.outro('Report generated successfully!');
prompts.outro('Done!');
}
4 changes: 1 addition & 3 deletions src/detect-and-pack-node.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,9 @@ import path from 'node:path';
import {pack as packAsTarball} from '@publint/pack';
import type {Options} from './types.js';

type ExtractStringLiteral<T> = T extends string ? T : never;

export async function detectAndPack(
root: string,
pack: ExtractStringLiteral<Options['pack']>
pack: Exclude<Extract<Options['pack'], string>, 'none'>
) {
let packageManager = pack;

Expand Down
Loading