diff --git a/package-lock.json b/package-lock.json index daeecf1..88529fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -15,6 +15,8 @@ "gunshi": "^0.14.3", "package-manager-detector": "^1.1.0", "picocolors": "^1.1.1", + "pino": "^9.7.0", + "pino-pretty": "^13.0.0", "publint": "^0.3.9" }, "bin": { @@ -1688,6 +1690,14 @@ "node": ">=12" } }, + "node_modules/atomic-sleep": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/atomic-sleep/-/atomic-sleep-1.0.0.tgz", + "integrity": "sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==", + "engines": { + "node": ">=8.0.0" + } + }, "node_modules/balanced-match": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-1.0.2.tgz", @@ -1809,6 +1819,11 @@ "dev": true, "license": "MIT" }, + "node_modules/colorette": { + "version": "2.0.20", + "resolved": "https://registry.npmjs.org/colorette/-/colorette-2.0.20.tgz", + "integrity": "sha512-IfEDxwoWIjkeXL1eXcDiow4UbKjhLdq6/EuSVR9GMN7KVH3r9gQ83e73hsz1Nd1T3ijd5xv1wcWRYO+D6kCI2w==" + }, "node_modules/concat-map": { "version": "0.0.1", "resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz", @@ -1831,6 +1846,14 @@ "node": ">= 8" } }, + "node_modules/dateformat": { + "version": "4.6.3", + "resolved": "https://registry.npmjs.org/dateformat/-/dateformat-4.6.3.tgz", + "integrity": "sha512-2P0p0pFGzHS5EMnhdxQi7aJN+iMheud0UhG4dlE1DLAlvL8JHjJJTX/CSm4JXwV0Ka5nGk3zC5mcb5bUQUxxMA==", + "engines": { + "node": "*" + } + }, "node_modules/debug": { "version": "4.4.0", "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.0.tgz", @@ -1880,6 +1903,14 @@ "dev": true, "license": "MIT" }, + "node_modules/end-of-stream": { + "version": "1.4.4", + "resolved": "https://registry.npmjs.org/end-of-stream/-/end-of-stream-1.4.4.tgz", + "integrity": "sha512-+uw1inIHVPQoaVuHzRyXd21icM+cnt4CzD5rW+NC1wjOUSTOs+Te7FOv7AhN7vS9x/oIyhLP5PR1H+phQAHu5Q==", + "dependencies": { + "once": "^1.4.0" + } + }, "node_modules/es-module-lexer": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.6.0.tgz", @@ -2116,6 +2147,11 @@ "node": ">=12.0.0" } }, + "node_modules/fast-copy": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/fast-copy/-/fast-copy-3.0.2.tgz", + "integrity": "sha512-dl0O9Vhju8IrcLndv2eU4ldt1ftXMqqfgN4H1cpmGV7P6jeB9FwpN9a2c8DPGE1Ys88rNUJVYDHq73CGAGOPfQ==" + }, "node_modules/fast-deep-equal": { "version": "3.1.3", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", @@ -2167,6 +2203,19 @@ "dev": true, "license": "MIT" }, + "node_modules/fast-redact": { + "version": "3.5.0", + "resolved": "https://registry.npmjs.org/fast-redact/-/fast-redact-3.5.0.tgz", + "integrity": "sha512-dwsoQlS7h9hMeYUq1W++23NDcBLV4KqONnITDV9DjfS3q1SgDGVrBdvvTLUotWtPSD7asWDV9/CmsZPy8Hf70A==", + "engines": { + "node": ">=6" + } + }, + "node_modules/fast-safe-stringify": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/fast-safe-stringify/-/fast-safe-stringify-2.1.1.tgz", + "integrity": "sha512-W+KJc2dmILlPplD/H4K9l9LcAHAfPtP6BY84uVLXQ6Evcz9Lcg33Y2z1IVblT6xdY54PXYVHEv+0Wpq8Io6zkA==" + }, "node_modules/fastq": { "version": "1.19.1", "resolved": "https://registry.npmjs.org/fastq/-/fastq-1.19.1.tgz", @@ -2384,6 +2433,11 @@ "node": ">=8" } }, + "node_modules/help-me": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/help-me/-/help-me-5.0.0.tgz", + "integrity": "sha512-7xgomUX6ADmcYzFik0HzAxh/73YlKR9bmFzf51CZwR+b6YtzU2m0u49hQCqV6SvlqIqsaxovfwdvbnsw3b/zpg==" + }, "node_modules/html-escaper": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", @@ -2548,6 +2602,14 @@ "@pkgjs/parseargs": "^0.11.0" } }, + "node_modules/joycon": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/joycon/-/joycon-3.1.1.tgz", + "integrity": "sha512-34wB/Y7MW7bzjKRjUKTa46I2Z7eV62Rkhva+KkopW7Qvv/OSWBqvkSY7vusOPrNuZcUG3tApvdVgNB8POj3SPw==", + "engines": { + "node": ">=10" + } + }, "node_modules/js-yaml": { "version": "4.1.0", "resolved": "https://registry.npmjs.org/js-yaml/-/js-yaml-4.1.0.tgz", @@ -2717,6 +2779,14 @@ "node": "*" } }, + "node_modules/minimist": { + "version": "1.2.8", + "resolved": "https://registry.npmjs.org/minimist/-/minimist-1.2.8.tgz", + "integrity": "sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==", + "funding": { + "url": "https://github.com/sponsors/ljharb" + } + }, "node_modules/minipass": { "version": "7.1.2", "resolved": "https://registry.npmjs.org/minipass/-/minipass-7.1.2.tgz", @@ -2769,6 +2839,22 @@ "dev": true, "license": "MIT" }, + "node_modules/on-exit-leak-free": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/on-exit-leak-free/-/on-exit-leak-free-2.1.2.tgz", + "integrity": "sha512-0eJJY6hXLGf1udHwfNftBqH+g73EU4B504nZeKpz1sYRKafAghwxEJunB2O7rDZkL4PGfsMVnTXZ2EjibbqcsA==", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/once": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz", + "integrity": "sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==", + "dependencies": { + "wrappy": "1" + } + }, "node_modules/optionator": { "version": "0.9.4", "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", @@ -2918,6 +3004,63 @@ "url": "https://github.com/sponsors/jonschlinkert" } }, + "node_modules/pino": { + "version": "9.7.0", + "resolved": "https://registry.npmjs.org/pino/-/pino-9.7.0.tgz", + "integrity": "sha512-vnMCM6xZTb1WDmLvtG2lE/2p+t9hDEIvTWJsu6FejkE62vB7gDhvzrpFR4Cw2to+9JNQxVnkAKVPA1KPB98vWg==", + "dependencies": { + "atomic-sleep": "^1.0.0", + "fast-redact": "^3.1.1", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pino-std-serializers": "^7.0.0", + "process-warning": "^5.0.0", + "quick-format-unescaped": "^4.0.3", + "real-require": "^0.2.0", + "safe-stable-stringify": "^2.3.1", + "sonic-boom": "^4.0.1", + "thread-stream": "^3.0.0" + }, + "bin": { + "pino": "bin.js" + } + }, + "node_modules/pino-abstract-transport": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/pino-abstract-transport/-/pino-abstract-transport-2.0.0.tgz", + "integrity": "sha512-F63x5tizV6WCh4R6RHyi2Ml+M70DNRXt/+HANowMflpgGFMAym/VKm6G7ZOQRjqN7XbGxK1Lg9t6ZrtzOaivMw==", + "dependencies": { + "split2": "^4.0.0" + } + }, + "node_modules/pino-pretty": { + "version": "13.0.0", + "resolved": "https://registry.npmjs.org/pino-pretty/-/pino-pretty-13.0.0.tgz", + "integrity": "sha512-cQBBIVG3YajgoUjo1FdKVRX6t9XPxwB9lcNJVD5GCnNM4Y6T12YYx8c6zEejxQsU0wrg9TwmDulcE9LR7qcJqA==", + "dependencies": { + "colorette": "^2.0.7", + "dateformat": "^4.6.3", + "fast-copy": "^3.0.2", + "fast-safe-stringify": "^2.1.1", + "help-me": "^5.0.0", + "joycon": "^3.1.1", + "minimist": "^1.2.6", + "on-exit-leak-free": "^2.1.0", + "pino-abstract-transport": "^2.0.0", + "pump": "^3.0.0", + "secure-json-parse": "^2.4.0", + "sonic-boom": "^4.0.1", + "strip-json-comments": "^3.1.1" + }, + "bin": { + "pino-pretty": "bin.js" + } + }, + "node_modules/pino-std-serializers": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/pino-std-serializers/-/pino-std-serializers-7.0.0.tgz", + "integrity": "sha512-e906FRY0+tV27iq4juKzSYPbUj2do2X2JX4EzSca1631EB2QJQUqGbDuERal7LCtOpxl6x3+nvo9NPZcmjkiFA==" + }, "node_modules/postcss": { "version": "8.5.3", "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.3.tgz", @@ -2986,6 +3129,21 @@ "url": "https://github.com/prettier/prettier?sponsor=1" } }, + "node_modules/process-warning": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/process-warning/-/process-warning-5.0.0.tgz", + "integrity": "sha512-a39t9ApHNx2L4+HBnQKqxxHNs1r7KF+Intd8Q/g1bUh6q0WIp9voPXJ/x0j+ZL45KF1pJd9+q2jLIRMfvEshkA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fastify" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/fastify" + } + ] + }, "node_modules/publint": { "version": "0.3.9", "resolved": "https://registry.npmjs.org/publint/-/publint-0.3.9.tgz", @@ -3016,6 +3174,15 @@ "quansync": "^0.2.7" } }, + "node_modules/pump": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.2.tgz", + "integrity": "sha512-tUPXtzlGM8FE3P0ZL6DVs/3P58k9nk8/jZeQCurTJylQA8qFYzHFfhBJkuqyE0FifOsQ0uKWekiZ5g8wtr28cw==", + "dependencies": { + "end-of-stream": "^1.1.0", + "once": "^1.3.1" + } + }, "node_modules/punycode": { "version": "2.3.1", "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", @@ -3063,6 +3230,19 @@ ], "license": "MIT" }, + "node_modules/quick-format-unescaped": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/quick-format-unescaped/-/quick-format-unescaped-4.0.4.tgz", + "integrity": "sha512-tYC1Q1hgyRuHgloV/YXs2w15unPVh8qfu/qCTfhTYamaw7fyhumKa2yGpdSo87vY32rIclj+4fWYQXUMs9EHvg==" + }, + "node_modules/real-require": { + "version": "0.2.0", + "resolved": "https://registry.npmjs.org/real-require/-/real-require-0.2.0.tgz", + "integrity": "sha512-57frrGM/OCTLqLOAh0mhVA9VBMHd+9U7Zb2THMGdBUoZVOtGbJzjxsYGDJ3A9AYYCP4hn6y1TVbaOfzWtm5GFg==", + "engines": { + "node": ">= 12.13.0" + } + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3159,6 +3339,19 @@ "node": ">=6" } }, + "node_modules/safe-stable-stringify": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/safe-stable-stringify/-/safe-stable-stringify-2.5.0.tgz", + "integrity": "sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==", + "engines": { + "node": ">=10" + } + }, + "node_modules/secure-json-parse": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/secure-json-parse/-/secure-json-parse-2.7.0.tgz", + "integrity": "sha512-6aU+Rwsezw7VR8/nyvKTx8QpWH9FrcYiXXlqC4z5d5XQBDRqtbfsRjnwGyqbi3gddNtWHuEk9OANUotL26qKUw==" + }, "node_modules/semver": { "version": "7.7.1", "resolved": "https://registry.npmjs.org/semver/-/semver-7.7.1.tgz", @@ -3220,6 +3413,14 @@ "integrity": "sha512-bLGGlR1QxBcynn2d5YmDX4MGjlZvy2MRBDRNHLJ8VI6l6+9FUiyTFNJ0IveOSP0bcXgVDPRcfGqA0pjaqUpfVg==", "license": "MIT" }, + "node_modules/sonic-boom": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/sonic-boom/-/sonic-boom-4.2.0.tgz", + "integrity": "sha512-INb7TM37/mAcsGmc9hyyI6+QR3rR1zVRu36B0NeGXKnOOLiZOfER5SA+N7X7k3yUYRzLWafduTDvJAfDswwEww==", + "dependencies": { + "atomic-sleep": "^1.0.0" + } + }, "node_modules/source-map-js": { "version": "1.2.1", "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", @@ -3230,6 +3431,14 @@ "node": ">=0.10.0" } }, + "node_modules/split2": { + "version": "4.2.0", + "resolved": "https://registry.npmjs.org/split2/-/split2-4.2.0.tgz", + "integrity": "sha512-UcjcJOWknrNkF6PLX83qcHM6KHgVKNkV62Y8a5uYDVv9ydGQVwAHMKqHdJje1VTWpljG0WYpCDhrCdAOYH4TWg==", + "engines": { + "node": ">= 10.x" + } + }, "node_modules/stackback": { "version": "0.0.2", "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", @@ -3352,7 +3561,6 @@ "version": "3.1.1", "resolved": "https://registry.npmjs.org/strip-json-comments/-/strip-json-comments-3.1.1.tgz", "integrity": "sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -3415,6 +3623,14 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/thread-stream": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/thread-stream/-/thread-stream-3.1.0.tgz", + "integrity": "sha512-OqyPZ9u96VohAyMfJykzmivOrY2wfMSf3C5TtFJVgN+Hm6aj+voFhlK+kZEIv2FBh1X6Xp3DlnCOfEQ3B2J86A==", + "dependencies": { + "real-require": "^0.2.0" + } + }, "node_modules/tinybench": { "version": "2.9.0", "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", @@ -3864,6 +4080,11 @@ "url": "https://github.com/chalk/ansi-styles?sponsor=1" } }, + "node_modules/wrappy": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/wrappy/-/wrappy-1.0.2.tgz", + "integrity": "sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==" + }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/package.json b/package.json index a9370dc..bed6c7b 100644 --- a/package.json +++ b/package.json @@ -6,10 +6,16 @@ "bin": { "e18e-report": "cli.js" }, - "exports": "./lib/index.js", + "exports": { + ".": { + "types": "./lib/index.d.ts", + "browser": "./lib/index.js", + "default": "./lib/index.js" + } + }, "imports": { "#detect-and-pack": { - "types": "./src/detect-and-pack-node.ts", + "types": "./lib/detect-and-pack-node.d.ts", "browser": "./lib/detect-and-pack-browser.js", "default": "./lib/detect-and-pack-node.js" } @@ -52,6 +58,8 @@ "gunshi": "^0.14.3", "package-manager-detector": "^1.1.0", "picocolors": "^1.1.1", + "pino": "^9.7.0", + "pino-pretty": "^13.0.0", "publint": "^0.3.9" }, "devDependencies": { diff --git a/src/analyze-dependencies.ts b/src/analyze-dependencies.ts new file mode 100644 index 0000000..261f3dc --- /dev/null +++ b/src/analyze-dependencies.ts @@ -0,0 +1,252 @@ +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'; + +// Create a logger instance with pretty printing for development +const logger = pino({ + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + }, +}); + +/** + * This file contains dependency analysis functionality. + * + * To enable debug logging for dependency analysis: + * ```typescript + * // Enable all debug logs + * logger.level = 'debug'; + * + * // Or create a specific logger for dependency analysis + * const analyzerLogger = logger.child({ module: 'analyzer' }); + * analyzerLogger.level = 'debug'; + * ``` + */ + +// Re-export types +export type { DependencyStats, DependencyAnalyzer }; + +export class LocalDependencyAnalyzer implements DependencyAnalyzer { + async analyzeDependencies(root: string): Promise { + 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() + ) { + try { + const entries = await fs.readdir(dir, {withFileTypes: true}); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + // Handle symlinks + if (entry.isSymbolicLink()) { + try { + const realPath = await fs.realpath(fullPath); + logger.debug('Found symlink:', fullPath, '->', realPath); + // If the real path is a package, process it + const pkgJsonPath = path.join(realPath, 'package.json'); + try { + const pkgJson = JSON.parse( + await fs.readFile(pkgJsonPath, 'utf-8') + ); + if (!seenPackages.has(pkgJson.name)) { + seenPackages.add(pkgJson.name); + logger.debug('Detected package (symlink):', pkgJson.name, 'at', realPath); + callbacks.onPackage(pkgJson); + } else { + logger.debug('Already seen package (symlink):', pkgJson.name, 'at', realPath); + } + } catch { + // Not a package or can't read package.json, continue + } + // Only follow symlinks that point to node_modules + if (realPath.includes('node_modules')) { + logger.debug('Following symlink to:', realPath); + await this.walkNodeModules(realPath, callbacks, seenPackages); + } + } catch { + logger.debug('Error resolving symlink:', fullPath); + } + continue; + } + + if (entry.isDirectory()) { + // Check if this is a package directory + const pkgJsonPath = path.join(fullPath, 'package.json'); + try { + const pkgJson = JSON.parse(await fs.readFile(pkgJsonPath, 'utf-8')); + // Only process each package once + if (!seenPackages.has(pkgJson.name)) { + seenPackages.add(pkgJson.name); + logger.debug('Detected package:', pkgJson.name, 'at', fullPath); + callbacks.onPackage(pkgJson); + } else { + logger.debug('Already seen package:', pkgJson.name, 'at', fullPath); + } + } catch { + // Not a package or can't read package.json, continue walking + } + + // Continue walking if it's not node_modules + if (entry.name !== 'node_modules') { + await this.walkNodeModules(fullPath, callbacks, seenPackages); + } + } else { + callbacks.onFile(fullPath); + } + } + } catch { + logger.debug('Error walking directory:', dir); + } + } +} + +// Keep the existing tarball analysis for backward compatibility +export async function analyzeDependencies( + tarball: ArrayBuffer +): Promise { + const {files, rootDir} = await unpack(tarball); + const decoder = new TextDecoder(); + + // Debug: Log all files in the tarball + logger.debug('Files in tarball:'); + for (const file of files) { + logger.debug(`- ${file.name}`); + } + + // 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.'); + } + + const pkg = JSON.parse(decoder.decode(pkgJson.data)); + + // Calculate total size + const installSize = files.reduce( + (acc, file) => acc + file.data.byteLength, + 0 + ); + + // Count dependencies + const directDependencies = Object.keys(pkg.dependencies || {}).length; + const devDependencies = Object.keys(pkg.devDependencies || {}).length; + + // Count CJS vs ESM dependencies + let cjsDependencies = 0; + 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 + } + } + } + + return { + totalDependencies: directDependencies + devDependencies, + directDependencies, + devDependencies, + cjsDependencies, + esmDependencies, + installSize, + packageName: pkg.name, + version: pkg.version, + tarballFiles: files.map((f) => f.name) + }; +} diff --git a/src/cli.ts b/src/cli.ts index 42909c8..aaca6ba 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -4,22 +4,45 @@ import {cli, define} from 'gunshi'; import * as prompts from '@clack/prompts'; import c from 'picocolors'; import {report} from './index.js'; -import type {Message, PackType} from './types.js'; +import type {PackType} from './types.js'; +import {LocalDependencyAnalyzer} from './analyze-dependencies.js'; +import { pino } from 'pino'; const version = createRequire(import.meta.url)('../package.json').version; const allowedPackTypes: PackType[] = ['auto', 'npm', 'yarn', 'pnpm', 'bun']; +// Create a logger instance with pretty printing for development +const logger = pino({ + transport: { + target: 'pino-pretty', + options: { + colorize: true, + translateTime: 'SYS:standard', + ignore: 'pid,hostname', + }, + }, +}); + const defaultCommand = define({ options: { pack: { type: 'string', default: 'auto', description: `Package manager to use for packing ('auto' | 'npm' | 'yarn' | 'pnpm' | 'bun')` + }, + 'log-level': { + type: 'string', + default: 'info', + description: 'Set the log level (debug | info | warn | error)' } }, async run(ctx) { const root = ctx.positionals[0]; let pack = ctx.values.pack as PackType; + const logLevel = ctx.values['log-level']; + + // Set the logger level based on the option + logger.level = logLevel; prompts.intro('Generating report...'); @@ -30,72 +53,140 @@ const defaultCommand = define({ process.exit(1); } - // If a path is passed, see if it's a path to a file (likely the tarball file) + // If a path is passed, it must be a tarball file + let isTarball = false; if (root) { - const stat = await fs.stat(root).catch(() => {}); - const isTarballFilePassed = stat?.isFile() === true; - if (!isTarballFilePassed) { + 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( + `When '--pack file' is used, a path to a tarball file must be passed.` + ); + process.exit(1); + } + } catch (error) { prompts.cancel( - `When '--pack file' is used, a path to a tarball file must be passed.` + `Failed to read tarball file: ${error instanceof Error ? error.message : String(error)}` ); process.exit(1); } - pack = {tarball: (await fs.readFile(root)).buffer}; } - const {info, messages} = await report({root, pack}); + // 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('Package info'); - prompts.log.message(`${c.dim('Name ')} ${info.name}`, {spacing: 0}); - prompts.log.message(`${c.dim('Version')} ${info.version}`, {spacing: 0}); - prompts.log.message(`${c.dim('Type ')} ${info.type.toUpperCase()}`, { - spacing: 0 - }); - - prompts.log.info('Package report'); + 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}); - if (messages.length === 0) { - prompts.outro('All good!'); - } else { - outputMessages(messages); - prompts.outro('Report found some issues.'); - process.exitCode = 1; + // 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}); } - } -}); -await cli(process.argv.slice(2), defaultCommand, { - name: 'e18e-report', - version, - description: 'Generate a performance report for your package.' -}); + // Then analyze the tarball + const {dependencies} = await report({root, pack}); -function outputMessages(messages: Message[]) { - const errors = messages.filter((v) => v.severity === 'error'); - if (errors.length) { - prompts.log.error('Errors found'); - for (let i = 0; i < errors.length; i++) { - const m = errors[i]; - prompts.log.message(c.dim(`${i + 1}. `) + m.message, {spacing: 0}); + // 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}`); + } } - process.exitCode = 1; - } - const warnings = messages.filter((v) => v.severity === 'warning'); - if (warnings.length) { - prompts.log.warning('Warnings found'); - for (let i = 0; i < warnings.length; i++) { - const m = warnings[i]; - prompts.log.message(c.dim(`${i + 1}. `) + m.message, {spacing: 0}); - } + prompts.log.info('Tarball Analysis'); + prompts.log.message( + `${c.cyan('Total deps ')} ${dependencies.totalDependencies}`, + {spacing: 0} + ); + prompts.log.message( + `${c.cyan('Direct deps ')} ${dependencies.directDependencies}`, + {spacing: 0} + ); + prompts.log.message( + `${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)}`, + {spacing: 0} + ); + prompts.log.message( + c.yellowBright( + 'Dependency type analysis is only available for local analysis, as tarballs do not include dependencies.' + ), + {spacing: 1} + ); + + prompts.log.info('Package report'); + prompts.log.message( + c.yellowBright( + 'This is a preview of the package report. The full report will be available soon.' + ), + {spacing: 1} + ); + + prompts.outro('Report generated successfully!'); } +}); - const suggestions = messages.filter((v) => v.severity === 'suggestion'); - if (suggestions.length) { - prompts.log.info('Suggestions found'); - for (let i = 0; i < suggestions.length; i++) { - const m = suggestions[i]; - prompts.log.message(c.dim(`${i + 1}. `) + m.message, {spacing: 0}); - } +function formatBytes(bytes: number) { + const units = ['B', 'KB', 'MB', 'GB']; + let size = bytes; + let unitIndex = 0; + + while (size >= 1024 && unitIndex < units.length - 1) { + size /= 1024; + unitIndex++; } + + return `${size.toFixed(1)} ${units[unitIndex]}`; } + +cli(process.argv.slice(2), defaultCommand, { + name: 'e18e-report', + version, + description: 'Generate a performance report for your package.' +}); diff --git a/src/index.ts b/src/index.ts index d203e68..1576338 100644 --- a/src/index.ts +++ b/src/index.ts @@ -12,6 +12,10 @@ import {groupProblemsByKind} from '@arethetypeswrong/core/utils'; import {unpack} from '@publint/pack'; import {analyzePackageModuleType} from './compute-type.js'; import type {PackageModuleType} from './compute-type.js'; +import { + analyzeDependencies, + type DependencyStats +} from './analyze-dependencies.js'; export type {Message, Options, PackageModuleType}; @@ -22,6 +26,7 @@ export interface ReportResult { type: PackageModuleType; }; messages: Message[]; + dependencies: DependencyStats; } export async function report(options: Options) { @@ -35,13 +40,13 @@ export async function report(options: Options) { } const info = await computeInfo(tarball); - + const dependencies = await analyzeDependencies(tarball); const attwResult = await runAttw(tarball); const publintResult = await runPublint(tarball); const messages = [...attwResult, ...publintResult]; - return {info, messages}; + return {info, messages, dependencies}; } async function computeInfo(tarball: ArrayBuffer) { diff --git a/src/test/analyze-dependencies.test.ts b/src/test/analyze-dependencies.test.ts new file mode 100644 index 0000000..8df8692 --- /dev/null +++ b/src/test/analyze-dependencies.test.ts @@ -0,0 +1,24 @@ +import {describe, it, expect} from 'vitest'; +import {analyzeDependencies} from '../analyze-dependencies.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +// Integration test using a real tarball fixture + +describe('analyzeDependencies (integration)', () => { + it('should analyze a real tarball fixture', async () => { + const tarballPath = path.join(__dirname, 'fixtures', 'test-package.tgz'); + const tarballBuffer = await fs.readFile(tarballPath); + const result = await analyzeDependencies(tarballBuffer.buffer); + expect(result).toMatchObject({ + totalDependencies: expect.any(Number), + directDependencies: expect.any(Number), + devDependencies: expect.any(Number), + cjsDependencies: expect.any(Number), + esmDependencies: expect.any(Number), + installSize: expect.any(Number), + packageName: 'test-package', + version: '1.0.0' + }); + }); +}); diff --git a/src/test/cli.test.ts b/src/test/cli.test.ts new file mode 100644 index 0000000..e162529 --- /dev/null +++ b/src/test/cli.test.ts @@ -0,0 +1,94 @@ +import {describe, it, expect, beforeAll, afterAll} from 'vitest'; +import {spawn} from 'node:child_process'; +import path from 'node:path'; +import fs from 'node:fs/promises'; +import {createTempDir, cleanupTempDir, createTestPackage} from './utils.js'; + +let mockTarballPath: string; +let tempDir: string; + +beforeAll(async () => { + // Create a temporary directory for the test package + tempDir = await createTempDir(); + + // Create a test package with some files + await createTestPackage(tempDir, { + name: 'mock-package', + version: '1.0.0', + type: 'module', + main: 'index.js', + dependencies: { + 'some-dep': '1.0.0' + } + }); + + // Create a simple index.js file + await fs.writeFile(path.join(tempDir, 'index.js'), 'console.log("Hello, world!");'); + + // Create node_modules with a dependency + const nodeModules = path.join(tempDir, 'node_modules'); + await fs.mkdir(nodeModules, {recursive: true}); + await fs.mkdir(path.join(nodeModules, 'some-dep'), {recursive: true}); + await fs.writeFile( + path.join(nodeModules, 'some-dep', 'package.json'), + JSON.stringify({ + name: 'some-dep', + version: '1.0.0', + type: 'module' + }) + ); + + // Run npm pack to create a tarball + const {stdout} = await new Promise<{stdout: string}>((resolve, reject) => { + const proc = spawn('npm', ['pack'], {cwd: tempDir}); + let stdout = ''; + proc.stdout.on('data', (data) => (stdout += data.toString())); + proc.on('close', (code) => { + if (code === 0) resolve({stdout}); + else reject(new Error(`npm pack failed with code ${code}`)); + }); + }); + + // The tarball is created in the current directory, so move it to our temp dir + const tarballName = stdout.trim(); + mockTarballPath = path.join(tempDir, tarballName); + await fs.rename(path.join(tempDir, tarballName), mockTarballPath); +}); + +afterAll(async () => { + await cleanupTempDir(tempDir); +}); + +function runCliProcess(args: string[], cwd?: string): Promise<{stdout: string; stderr: string; code: number | null}> { + return new Promise((resolve) => { + const cliPath = path.resolve(__dirname, '../../lib/cli.js'); + const proc = spawn('node', [cliPath, ...args], {env: process.env, cwd: cwd || process.cwd()}); + let stdout = ''; + let stderr = ''; + proc.stdout.on('data', (data) => (stdout += data.toString())); + proc.stderr.on('data', (data) => (stderr += data.toString())); + proc.on('close', (code) => resolve({stdout, stderr, code})); + }); +} + +describe('CLI', () => { + it('should run successfully with default options', async () => { + const {stdout, stderr, code} = await runCliProcess([mockTarballPath], tempDir); + if (code !== 0) { + console.error('CLI Error:', stderr); + } + expect(code).toBe(0); + // No local analysis for tarball input + // expect(stdout).toContain('Local Analysis'); + expect(stdout).toContain('Tarball Analysis'); + }); + + it('should show tarball files when --log-level=debug is used', async () => { + const {stdout, stderr, code} = await runCliProcess([mockTarballPath, '--log-level=debug'], tempDir); + if (code !== 0) { + console.error('CLI Error:', stderr); + } + expect(code).toBe(0); + expect(stdout).toContain('Files in tarball:'); + }); +}); \ No newline at end of file diff --git a/src/test/fixtures/test-package.tgz b/src/test/fixtures/test-package.tgz new file mode 100644 index 0000000..206032f Binary files /dev/null and b/src/test/fixtures/test-package.tgz differ diff --git a/src/test/fixtures/test-package/package.json b/src/test/fixtures/test-package/package.json new file mode 100644 index 0000000..39a9680 --- /dev/null +++ b/src/test/fixtures/test-package/package.json @@ -0,0 +1,12 @@ +{ + "name": "test-package", + "version": "1.0.0", + "main": "index.js", + "scripts": { + "test": "echo \"Error: no test specified\" && exit 1" + }, + "keywords": [], + "author": "", + "license": "ISC", + "description": "" +} diff --git a/src/test/local-analyzer.test.ts b/src/test/local-analyzer.test.ts new file mode 100644 index 0000000..bf7cd45 --- /dev/null +++ b/src/test/local-analyzer.test.ts @@ -0,0 +1,161 @@ +import {describe, it, expect, beforeEach, afterEach} from 'vitest'; +import {LocalDependencyAnalyzer} from '../analyze-dependencies.js'; +import { + createTempDir, + cleanupTempDir, + createTestPackage, + createTestPackageWithDependencies, + type TestPackage +} from './utils.js'; +import fs from 'node:fs/promises'; +import path from 'node:path'; + +describe('LocalDependencyAnalyzer', () => { + let tempDir: string; + let analyzer: LocalDependencyAnalyzer; + + beforeEach(async () => { + tempDir = await createTempDir(); + analyzer = new LocalDependencyAnalyzer(); + }); + + afterEach(async () => { + await cleanupTempDir(tempDir); + }); + + it('should handle empty project', async () => { + await createTestPackage(tempDir, { + name: 'test-package', + version: '1.0.0' + }); + + const stats = await analyzer.analyzeDependencies(tempDir); + expect(stats).toEqual({ + totalDependencies: 0, + directDependencies: 0, + devDependencies: 0, + cjsDependencies: 0, + esmDependencies: 0, + installSize: 0, + packageName: 'test-package', + version: '1.0.0' + }); + }); + + it('should analyze dependencies correctly', async () => { + const rootPackage: TestPackage = { + name: 'test-package', + version: '1.0.0', + dependencies: { + 'cjs-package': '1.0.0', + 'esm-package': '1.0.0' + }, + devDependencies: { + 'dev-package': '1.0.0' + } + }; + + const dependencies: TestPackage[] = [ + { + name: 'cjs-package', + version: '1.0.0', + main: 'index.js', + type: 'commonjs' + }, + { + name: 'esm-package', + version: '1.0.0', + type: 'module', + exports: { + '.': { + import: './index.js' + } + } + }, + { + name: 'dev-package', + version: '1.0.0', + type: 'commonjs' + } + ]; + + await createTestPackageWithDependencies(tempDir, rootPackage, dependencies); + + const stats = await analyzer.analyzeDependencies(tempDir); + expect(stats).toEqual({ + totalDependencies: 3, + directDependencies: 2, + devDependencies: 1, + cjsDependencies: 2, // cjs-package and dev-package + esmDependencies: 1, // esm-package + installSize: expect.any(Number), + packageName: 'test-package', + version: '1.0.0' + }); + }); + + it('should handle symlinks', async () => { + // Create root package + await createTestPackage( + tempDir, + { + name: 'test-package', + version: '1.0.0', + dependencies: { + 'linked-package': '1.0.0' + } + }, + {createNodeModules: true} + ); + + // Create a package that will be linked + const realPkg = path.join(tempDir, 'real-package'); + await fs.mkdir(realPkg); + await createTestPackage(realPkg, { + name: 'linked-package', + version: '1.0.0', + type: 'module' + }); + + // Create a symlink to the real package + await fs.symlink( + realPkg, + path.join(tempDir, 'node_modules', 'linked-package'), + 'dir' + ); + + const stats = await analyzer.analyzeDependencies(tempDir); + expect(stats).toEqual({ + totalDependencies: 1, + directDependencies: 1, + devDependencies: 0, + cjsDependencies: 0, + esmDependencies: 1, + installSize: expect.any(Number), + packageName: 'test-package', + version: '1.0.0' + }); + }); + + it('should handle missing node_modules', async () => { + await createTestPackage(tempDir, { + name: 'test-package', + version: '1.0.0', + dependencies: { + 'some-package': '1.0.0' + } + }); + + const stats = await analyzer.analyzeDependencies(tempDir); + expect(stats).toEqual({ + totalDependencies: 1, + directDependencies: 1, + devDependencies: 0, + cjsDependencies: 0, + esmDependencies: 0, + installSize: 0, + packageName: 'test-package', + version: '1.0.0' + }); + }); +}); diff --git a/src/test/utils.ts b/src/test/utils.ts new file mode 100644 index 0000000..072de60 --- /dev/null +++ b/src/test/utils.ts @@ -0,0 +1,74 @@ +import fs from 'node:fs/promises'; +import path from 'node:path'; +import os from 'node:os'; + +export interface TestPackage { + name: string; + version: string; + type?: 'module' | 'commonjs'; + dependencies?: Record; + devDependencies?: Record; + main?: string; + exports?: Record; +} + +export interface TestPackageSetup { + root: string; + packages: TestPackage[]; +} + +export async function createTempDir(): Promise { + return fs.mkdtemp(path.join(os.tmpdir(), 'reporter-test-')); +} + +export async function cleanupTempDir(dir: string): Promise { + await fs.rm(dir, {recursive: true, force: true}); +} + +export async function createTestPackage( + root: string, + pkg: TestPackage, + options: {createNodeModules?: boolean} = {} +): Promise { + // Create package.json + await fs.writeFile( + path.join(root, 'package.json'), + JSON.stringify(pkg, null, 2) + ); + + // Create node_modules if requested + if (options.createNodeModules) { + await fs.mkdir(path.join(root, 'node_modules')); + } +} + +export async function createTestPackageWithDependencies( + root: string, + pkg: TestPackage, + dependencies: TestPackage[] +): Promise { + // Create root package + await createTestPackage(root, pkg, {createNodeModules: true}); + + // Create dependencies + const nodeModules = path.join(root, 'node_modules'); + for (const dep of dependencies) { + const depDir = path.join(nodeModules, dep.name); + await fs.mkdir(depDir); + await createTestPackage(depDir, dep); + } +} + +export function createMockTarball(files: Array<{name: string; content: any}>) { + return { + files: files.map((file) => ({ + name: file.name, + data: new TextEncoder().encode( + typeof file.content === 'string' + ? file.content + : JSON.stringify(file.content) + ) + })), + rootDir: 'package' + }; +} diff --git a/src/types.ts b/src/types.ts index fa36d53..3de2913 100644 --- a/src/types.ts +++ b/src/types.ts @@ -21,3 +21,19 @@ export interface Message { score: number; message: string; } + +export interface DependencyStats { + totalDependencies: number; + directDependencies: number; + devDependencies: number; + cjsDependencies: number; + esmDependencies: number; + installSize: number; + tarballFiles?: string[]; + packageName?: string; + version?: string; +} + +export interface DependencyAnalyzer { + analyzeDependencies(root?: string): Promise; +} diff --git a/vitest.config.ts b/vitest.config.ts index 597dffe..435447f 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -7,6 +7,8 @@ export default defineConfig({ reporters: 'dot', include: [ 'packages/**/*.test.ts', + 'src/**/*.test.ts', + 'test/**/*.test.ts' ] } })