@dependably/npm-check 1.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,229 @@
1
+ // src/pnpm-format.js
2
+ // pnpm-lock.yaml support. pnpm's lockfile is a different shape from npm's:
3
+ // - YAML, not JSON (`lockfileVersion` is a string like '9.0')
4
+ // - `importers:` declares each workspace's direct deps (the root is the '.' importer)
5
+ // - `packages:` is keyed by `name@version` (not by install path) and carries the
6
+ // `resolution.integrity` we verify against the registry
7
+ // - registry packages store NO `resolved` tarball URL — the registry is implied by
8
+ // config (.npmrc), so we resolve the per-package registry base from that instead
9
+ // of parsing it out of a URL the way the npm path does.
10
+ //
11
+ // This module gives the rest of the toolkit a uniform view: forEachPnpmPackageEntry
12
+ // emits the SAME callback shape as format-library's npm walker (so the integrity /
13
+ // vuln / deprecation checkers iterate it unchanged), plus a precomputed `registryBase`
14
+ // and a normalized `node`.
15
+ import { DEFAULT_REGISTRY } from './integrity.js';
16
+
17
+ // Strip trailing slashes without a regex (linear scan, no backtracking).
18
+ function stripTrailingSlash(url) {
19
+ let base = url;
20
+ while (base.endsWith('/')) base = base.slice(0, -1);
21
+ return base;
22
+ }
23
+
24
+ /**
25
+ * Resolve the registry base for a pnpm package from the project's registry config.
26
+ * Scoped names honour a matching `@scope:registry`; everything else uses the default
27
+ * `registry`, falling back to the public npm registry. Preserves private-registry
28
+ * support without a `resolved` URL to parse.
29
+ * @param {string|null} name - Real package name (may be scoped)
30
+ * @param {object} registryConfig - { registry, scopedRegistries }
31
+ * @returns {string} Registry base URL
32
+ */
33
+ export function resolvePnpmRegistryBase(name, registryConfig = {}) {
34
+ const { registry = DEFAULT_REGISTRY, scopedRegistries = {} } = registryConfig;
35
+ if (name && name.startsWith('@')) {
36
+ const slash = name.indexOf('/');
37
+ const scope = slash === -1 ? name : name.slice(0, slash);
38
+ if (scopedRegistries[scope]) return stripTrailingSlash(scopedRegistries[scope]);
39
+ }
40
+ return stripTrailingSlash(registry || DEFAULT_REGISTRY);
41
+ }
42
+
43
+ /**
44
+ * Split a pnpm depPath into { name, version }. Handles every pnpm key form the
45
+ * flavor layer routes here:
46
+ * - v9 (`lockfileVersion '9.0'`): `lodash@4.17.21`, `@scope/pkg@1.0.0`
47
+ * - v6 (`'6.0'`, pnpm 8): `/lodash@4.17.21`, `/@scope/pkg@1.0.0`
48
+ * - v5 (`'5.x'`, pnpm 6-7): `/lodash/4.17.21`, `/@scope/pkg/1.0.0`
49
+ * plus peer suffixes: paren style `foo@1.0.0(react@18.0.0)` (v6/v9) and the older
50
+ * v5 underscore style `react-dom/16.13.1_react@16.13.1`.
51
+ *
52
+ * The paren peer suffix is stripped FIRST so the inner `@` of a peer (`react@18`)
53
+ * can't be mistaken for the version separator. A leading `/` (v5/v6 key form) is then
54
+ * stripped, and the name↔version separator is located past any `@scope/` prefix:
55
+ * whichever of `@` (v6/v9) or `/` (v5) comes first delimits the version. Local deps
56
+ * surface as `name@file:../x` / `name@link:../x`.
57
+ * @param {string} depPath - Key from the pnpm `packages` map
58
+ * @returns {{ name: string, version: string|null }}
59
+ */
60
+ export function parsePnpmDepPath(depPath) {
61
+ // 1. Strip a paren-style peer suffix (`(react@18.0.0)`, v6/v9).
62
+ const parenIdx = depPath.indexOf('(');
63
+ let bare = parenIdx === -1 ? depPath : depPath.slice(0, parenIdx);
64
+
65
+ // 2. Strip the leading slash of the v5/v6 key forms (v9 keys have none).
66
+ if (bare.startsWith('/')) bare = bare.slice(1);
67
+
68
+ // 3. Locate the name↔version separator past any `@scope/` prefix. The name is
69
+ // either `pkg` or `@scope/pkg`, so begin the search after the scope's slash.
70
+ let searchStart = 0;
71
+ if (bare.startsWith('@')) {
72
+ const scopeSlash = bare.indexOf('/');
73
+ if (scopeSlash !== -1) searchStart = scopeSlash + 1;
74
+ }
75
+ const atSep = bare.indexOf('@', searchStart);
76
+ const slashSep = bare.indexOf('/', searchStart);
77
+
78
+ // Slash separator wins only when it exists and precedes any `@` — the v5
79
+ // `/name/version` form. Its version may carry an underscore peer suffix
80
+ // (`16.13.1_react@16.13.1`), trimmed here (semver versions never contain `_`,
81
+ // so this is safe for the slash form).
82
+ if (slashSep !== -1 && (atSep === -1 || slashSep < atSep)) {
83
+ let version = bare.slice(slashSep + 1);
84
+ const underscore = version.indexOf('_');
85
+ if (underscore !== -1) version = version.slice(0, underscore);
86
+ return { name: bare.slice(0, slashSep), version: version || null };
87
+ }
88
+ // Otherwise `@` separates name from version (v6/v9). `file:`/`link:` versions —
89
+ // which contain slashes after the `@` — land here and are preserved verbatim.
90
+ if (atSep > 0) {
91
+ return { name: bare.slice(0, atSep), version: bare.slice(atSep + 1) || null };
92
+ }
93
+ return { name: bare, version: null }; // name with no version / no separator
94
+ }
95
+
96
+ /**
97
+ * Classify a pnpm `packages` entry into the boolean flags the checkers already
98
+ * understand. Only a plain registry package (integrity, semver version, no
99
+ * tarball/git/local marker) is left verifiable; everything else is flagged so the
100
+ * existing skip logic in the checkers passes over it.
101
+ * @param {string} version - Version portion of the depPath
102
+ * @param {object} entry - The pnpm package entry (with `resolution`)
103
+ * @returns {{ kind: string, flags: object }}
104
+ */
105
+ function classifyPnpmPackage(version, entry) {
106
+ const resolution = (entry && entry.resolution) || {};
107
+ const flags = { isLink: false, isBundled: false, isGitDep: false, isFileDep: false };
108
+
109
+ if (typeof version === 'string' && version.startsWith('link:')) {
110
+ flags.isLink = true;
111
+ return { kind: 'link', flags };
112
+ }
113
+ if (typeof version === 'string' && (version.startsWith('file:') || resolution.directory)) {
114
+ flags.isFileDep = true;
115
+ return { kind: 'file', flags };
116
+ }
117
+ if (resolution.type === 'git' || resolution.repo || (typeof version === 'string' && version.startsWith('git'))) {
118
+ flags.isGitDep = true;
119
+ return { kind: 'git', flags };
120
+ }
121
+ if (resolution.tarball) {
122
+ // Remote URL-tarball dep — may or may not carry its own `integrity`. pnpm records
123
+ // BOTH `tarball` and `integrity` for these (a plain registry entry has integrity
124
+ // and NO tarball). The `version` parsed from the key is the tarball URL, not a
125
+ // registry version, so no registry advisory/manifest applies — treat like a
126
+ // file/url dep so the checkers skip it, regardless of integrity.
127
+ flags.isFileDep = true;
128
+ return { kind: 'tarball', flags };
129
+ }
130
+ if (resolution.integrity) {
131
+ return { kind: 'registry', flags };
132
+ }
133
+ // Unknown / unverifiable shape — flag as file so it is skipped, not mis-checked.
134
+ flags.isFileDep = true;
135
+ return { kind: 'file', flags };
136
+ }
137
+
138
+ /**
139
+ * Iterate a parsed pnpm-lock.yaml, emitting one info object per importer (root /
140
+ * workspace) and per `packages` entry. The shape mirrors format-library's npm
141
+ * walker — { key, entry, name, isRoot, isWorkspaceSource, isLink, isBundled,
142
+ * isGitDep, isFileDep } — plus `registryBase` and a normalized `node`, so the
143
+ * downstream checkers iterate npm and pnpm uniformly.
144
+ *
145
+ * The registry config is read from `lockfile.__npmCheckMeta` (stamped by the
146
+ * parser from the sibling .npmrc); absent meta falls back to public-registry
147
+ * defaults.
148
+ * @param {object} lockfile - Parsed pnpm lockfile
149
+ * @param {function} callback - Called with each entry's info
150
+ */
151
+ // Per-package registry config, read from `lockfile.__npmCheckMeta` (stamped by the
152
+ // parser from the sibling .npmrc); absent meta falls back to public-registry defaults.
153
+ function pnpmRegistryConfig(lockfile) {
154
+ const meta = lockfile && lockfile.__npmCheckMeta;
155
+ return {
156
+ registry: (meta && meta.registry) || DEFAULT_REGISTRY,
157
+ scopedRegistries: (meta && meta.scopedRegistries) || {}
158
+ };
159
+ }
160
+
161
+ // Emit one importer (the root project '.' or a workspace package). These have no
162
+ // integrity to verify; emitting them keeps counts/iteration aligned with the npm
163
+ // root+workspace entries (the checkers skip both).
164
+ function emitPnpmImporter(importerKey, callback) {
165
+ const isRoot = importerKey === '.';
166
+ const node = { name: null, version: null, integrity: null, registryBase: null, kind: isRoot ? 'root' : 'workspace', path: importerKey };
167
+ callback({
168
+ key: importerKey,
169
+ entry: {},
170
+ name: null,
171
+ isRoot,
172
+ isWorkspaceSource: !isRoot,
173
+ isLink: false,
174
+ isBundled: false,
175
+ isGitDep: false,
176
+ isFileDep: false,
177
+ registryBase: null,
178
+ node
179
+ });
180
+ }
181
+
182
+ // Emit one resolved `packages` entry (keyed by `name@version`).
183
+ function emitPnpmPackage(depPath, entry, registryConfig, callback) {
184
+ const { name, version } = parsePnpmDepPath(depPath);
185
+ const { kind, flags } = classifyPnpmPackage(version, entry);
186
+ const resolution = (entry && entry.resolution) || {};
187
+ const integrity = resolution.integrity || null;
188
+ const registryBase = kind === 'registry' ? resolvePnpmRegistryBase(name, registryConfig) : null;
189
+
190
+ // Synthesize an npm-shaped `entry` so the existing checkers read it unchanged:
191
+ // version + integrity are the fields they consult; `resolved` is null for pnpm
192
+ // registry deps (the registry comes from `registryBase`, not a URL).
193
+ const synthEntry = {
194
+ version: version || undefined,
195
+ integrity: integrity || undefined,
196
+ resolved: resolution.tarball || undefined,
197
+ deprecated: entry && entry.deprecated
198
+ };
199
+
200
+ callback({
201
+ key: depPath,
202
+ entry: synthEntry,
203
+ name,
204
+ isRoot: false,
205
+ isWorkspaceSource: false,
206
+ isLink: flags.isLink,
207
+ isBundled: flags.isBundled,
208
+ isGitDep: flags.isGitDep,
209
+ isFileDep: flags.isFileDep,
210
+ registryBase,
211
+ node: { name, version: version || null, integrity, registryBase, kind, path: depPath }
212
+ });
213
+ }
214
+
215
+ export function forEachPnpmPackageEntry(lockfile, callback) {
216
+ const registryConfig = pnpmRegistryConfig(lockfile);
217
+
218
+ // 1. Importers: the root project ('.') and each workspace package.
219
+ const importers = (lockfile && lockfile.importers) || {};
220
+ for (const importerKey of Object.keys(importers)) {
221
+ emitPnpmImporter(importerKey, callback);
222
+ }
223
+
224
+ // 2. Packages: the resolved dependency set, keyed by `name@version`.
225
+ const packages = (lockfile && lockfile.packages) || {};
226
+ for (const [depPath, entry] of Object.entries(packages)) {
227
+ emitPnpmPackage(depPath, entry, registryConfig, callback);
228
+ }
229
+ }
@@ -0,0 +1,121 @@
1
+ // src/pnpm-workspace-validator.js
2
+ // Validate a project's pnpm-workspace.yaml — the file that, since pnpm 9/10, holds
3
+ // both the workspace package globs AND most pnpm settings that used to live in
4
+ // .npmrc / the package.json `pnpm` field. Same contract as the other validators:
5
+ // validatePnpmWorkspace(contentOrParsed, options) => { valid, errors, warnings, info }.
6
+ import { createRequire } from 'module';
7
+
8
+ const require = createRequire(import.meta.url);
9
+ let _yaml = null;
10
+ function loadYaml() {
11
+ if (!_yaml) _yaml = require('yaml');
12
+ return _yaml;
13
+ }
14
+
15
+ export class PnpmWorkspaceValidationError extends Error {
16
+ constructor(message, code) {
17
+ super(message);
18
+ this.name = 'PnpmWorkspaceValidationError';
19
+ this.code = code;
20
+ }
21
+ }
22
+
23
+ const isPlainObject = (v) => typeof v === 'object' && v !== null && !Array.isArray(v);
24
+ const isStringArray = (v) => Array.isArray(v) && v.every((x) => typeof x === 'string');
25
+
26
+ // Recognized top-level keys and the type each must have. Generous (warn-only on
27
+ // unknowns) rather than exhaustive — pnpm adds settings often.
28
+ const KNOWN_KEYS = {
29
+ packages: isStringArray,
30
+ catalog: isPlainObject,
31
+ catalogs: isPlainObject,
32
+ overrides: isPlainObject,
33
+ packageExtensions: isPlainObject,
34
+ peerDependencyRules: isPlainObject,
35
+ patchedDependencies: isPlainObject,
36
+ allowedDeprecatedVersions: isPlainObject,
37
+ onlyBuiltDependencies: isStringArray,
38
+ neverBuiltDependencies: isStringArray,
39
+ ignoredBuiltDependencies: isStringArray,
40
+ onlyBuiltDependenciesFile: (v) => typeof v === 'string',
41
+ packageConfigs: isPlainObject,
42
+ // Common scalar settings that legitimately live here in pnpm 9/10.
43
+ nodeLinker: (v) => typeof v === 'string',
44
+ shamefullyHoist: (v) => typeof v === 'boolean',
45
+ hoistPattern: isStringArray,
46
+ publicHoistPattern: isStringArray,
47
+ autoInstallPeers: (v) => typeof v === 'boolean',
48
+ dedupePeerDependents: (v) => typeof v === 'boolean',
49
+ strictPeerDependencies: (v) => typeof v === 'boolean',
50
+ excludeLinksFromLockfile: (v) => typeof v === 'boolean',
51
+ enablePrePostScripts: (v) => typeof v === 'boolean',
52
+ virtualStoreDir: (v) => typeof v === 'string',
53
+ preferWorkspacePackages: (v) => typeof v === 'boolean',
54
+ linkWorkspacePackages: (v) => typeof v === 'boolean' || typeof v === 'string',
55
+ saveWorkspaceProtocol: (v) => typeof v === 'boolean' || typeof v === 'string'
56
+ };
57
+
58
+ const TYPE_LABEL = new Map([
59
+ [isStringArray, 'an array of strings'],
60
+ [isPlainObject, 'an object']
61
+ ]);
62
+ function expectedLabel(validator) {
63
+ return TYPE_LABEL.get(validator) || 'the correct type';
64
+ }
65
+
66
+ /**
67
+ * Parse pnpm-workspace.yaml content into an object (lazy `yaml` dep). Throws on
68
+ * malformed YAML so callers can report it as a single structural error.
69
+ * @param {string} content - Raw file contents
70
+ * @returns {object} Parsed document (an empty file yields {})
71
+ */
72
+ export function parsePnpmWorkspace(content) {
73
+ const data = loadYaml().parse(content);
74
+ return data == null ? {} : data;
75
+ }
76
+
77
+ /**
78
+ * Validate a parsed (or raw-string) pnpm-workspace.yaml.
79
+ * @param {string|object} input - Raw YAML string or already-parsed object
80
+ * @param {object} options - { strictMode }
81
+ * @returns {{ valid, errors, warnings, info }}
82
+ */
83
+ export function validatePnpmWorkspace(input, options = {}) {
84
+ const errors = [];
85
+ const warnings = [];
86
+ const info = {};
87
+
88
+ let doc;
89
+ if (typeof input === 'string') {
90
+ try {
91
+ doc = parsePnpmWorkspace(input);
92
+ } catch (e) {
93
+ errors.push(new PnpmWorkspaceValidationError(`invalid YAML in pnpm-workspace.yaml: ${e.message}`, 'PNPM_WS_SYNTAX'));
94
+ return { valid: false, errors, warnings, info };
95
+ }
96
+ } else {
97
+ doc = input || {};
98
+ }
99
+
100
+ if (!isPlainObject(doc)) {
101
+ errors.push(new PnpmWorkspaceValidationError('pnpm-workspace.yaml must be a YAML mapping', 'PNPM_WS_NOT_OBJECT'));
102
+ return { valid: false, errors, warnings, info };
103
+ }
104
+
105
+ info.hasPackages = Array.isArray(doc.packages);
106
+ info.keys = Object.keys(doc);
107
+
108
+ for (const [key, value] of Object.entries(doc)) {
109
+ const validator = KNOWN_KEYS[key];
110
+ if (!validator) {
111
+ warnings.push({ code: 'PNPM_WS_UNKNOWN_KEY', message: `unrecognized pnpm-workspace.yaml key "${key}"` });
112
+ continue;
113
+ }
114
+ if (!validator(value)) {
115
+ errors.push(new PnpmWorkspaceValidationError(`"${key}" must be ${expectedLabel(validator)}`, 'PNPM_WS_INVALID_TYPE'));
116
+ }
117
+ }
118
+
119
+ const valid = errors.length === 0 && !(options.strictMode && warnings.length > 0);
120
+ return { valid, errors, warnings, info };
121
+ }
@@ -0,0 +1,245 @@
1
+ /**
2
+ * Progress reporting utilities for long-running operations.
3
+ * Provides real-time progress updates with time estimates and memory tracking.
4
+ */
5
+
6
+ import { EventEmitter } from 'events';
7
+ import { getMemoryStats } from './performance.js';
8
+
9
+ /**
10
+ * Progress information object structure
11
+ * @typedef {Object} ProgressInfo
12
+ * @property {number} current - Current item count
13
+ * @property {number} total - Total items
14
+ * @property {number} percentage - Progress percentage (0-100)
15
+ * @property {number} elapsed - Milliseconds elapsed
16
+ * @property {number} estimated - Estimated time remaining (ms)
17
+ * @property {Object|null} memory - Memory stats if enabled
18
+ * @property {string} stage - Current operation stage
19
+ */
20
+
21
+ /**
22
+ * ProgressReporter class for tracking operation progress
23
+ * Supports both callback and event emitter patterns
24
+ */
25
+ export class ProgressReporter extends EventEmitter {
26
+ /**
27
+ * Create a new ProgressReporter
28
+ * @param {number} total - Total number of items to process
29
+ * @param {Object} options - Options
30
+ * @param {Function} options.onProgress - Callback function(progressInfo)
31
+ * @param {number} options.updateInterval - Update interval in ms (default: 100)
32
+ * @param {boolean} options.showMemory - Include memory stats (default: false)
33
+ * @param {string} options.stage - Initial stage name (default: 'Processing')
34
+ */
35
+ constructor(total = 0, options = {}) {
36
+ super();
37
+ this.total = total;
38
+ this.current = 0;
39
+ this.startTime = Date.now();
40
+ this.lastUpdateTime = this.startTime;
41
+ this.onProgressCallback = options.onProgress || null;
42
+ this.updateInterval = options.updateInterval || 100;
43
+ this.showMemory = options.showMemory || false;
44
+ this.stage = options.stage || 'Processing';
45
+ this.finished = false;
46
+ this.lastReportedPercentage = -1;
47
+ }
48
+
49
+ /**
50
+ * Update progress to a specific value
51
+ * @param {number} current - Current item count
52
+ * @param {string} stage - Optional stage name
53
+ */
54
+ update(current, stage = null) {
55
+ if (this.finished) return;
56
+
57
+ this.current = Math.min(current, this.total);
58
+ if (stage) {
59
+ this.stage = stage;
60
+ }
61
+
62
+ const now = Date.now();
63
+ const timeSinceLastUpdate = now - this.lastUpdateTime;
64
+
65
+ // If consumers are listening or a callback is provided, report immediately
66
+ if (this.onProgressCallback || this.listenerCount('progress') > 0) {
67
+ this._report();
68
+ this.lastUpdateTime = now;
69
+ return;
70
+ }
71
+
72
+ // Otherwise throttle updates to avoid overhead
73
+ if (timeSinceLastUpdate >= this.updateInterval || this.current === this.total) {
74
+ this._report();
75
+ this.lastUpdateTime = now;
76
+ }
77
+ }
78
+
79
+ /**
80
+ * Increment progress by 1
81
+ * @param {string} stage - Optional stage name
82
+ */
83
+ increment(stage = null) {
84
+ this.update(this.current + 1, stage);
85
+ }
86
+
87
+ /**
88
+ * Set total items (useful when total is unknown initially)
89
+ * @param {number} total - Total number of items
90
+ */
91
+ setTotal(total) {
92
+ this.total = total;
93
+ // Recalculate and report immediately
94
+ this._report();
95
+ }
96
+
97
+ /**
98
+ * Finish progress reporting
99
+ * @param {string} message - Optional completion message
100
+ */
101
+ finish(message = null) {
102
+ if (this.finished) return;
103
+
104
+ this.current = this.total;
105
+ this.finished = true;
106
+ this._report(true);
107
+
108
+ if (message) {
109
+ this.emit('complete', message);
110
+ if (this.onProgressCallback) {
111
+ this.onProgressCallback(this._getProgressInfo(), message);
112
+ }
113
+ }
114
+ }
115
+
116
+ /**
117
+ * Reset progress reporter
118
+ * @param {number} total - New total (optional)
119
+ */
120
+ reset(total = null) {
121
+ this.current = 0;
122
+ this.startTime = Date.now();
123
+ this.lastUpdateTime = this.startTime;
124
+ this.finished = false;
125
+ this.lastReportedPercentage = -1;
126
+ if (total !== null) {
127
+ this.total = total;
128
+ }
129
+ }
130
+
131
+ /**
132
+ * Get current progress information
133
+ * @returns {ProgressInfo} Progress information object
134
+ */
135
+ _getProgressInfo() {
136
+ const elapsed = Date.now() - this.startTime;
137
+ const percentage = this.total > 0
138
+ ? Math.min(100, Math.round((this.current / this.total) * 100))
139
+ : 0;
140
+
141
+ // Calculate estimated time remaining
142
+ let estimated = 0;
143
+ if (this.current > 0 && this.current < this.total) {
144
+ const rate = this.current / elapsed; // items per ms
145
+ const remaining = this.total - this.current;
146
+ estimated = Math.round(remaining / rate);
147
+ }
148
+
149
+ const progressInfo = {
150
+ current: this.current,
151
+ total: this.total,
152
+ percentage,
153
+ elapsed,
154
+ estimated,
155
+ stage: this.stage,
156
+ memory: null
157
+ };
158
+
159
+ if (this.showMemory) {
160
+ progressInfo.memory = getMemoryStats();
161
+ }
162
+
163
+ return progressInfo;
164
+ }
165
+
166
+ /**
167
+ * Report progress (internal)
168
+ * @param {boolean} force - Force report even if percentage unchanged
169
+ */
170
+ _report(force = false) {
171
+ const progressInfo = this._getProgressInfo();
172
+
173
+ // Only report if percentage changed or forced
174
+ if (force || progressInfo.percentage !== this.lastReportedPercentage) {
175
+ this.lastReportedPercentage = progressInfo.percentage;
176
+
177
+ // Emit event
178
+ this.emit('progress', progressInfo);
179
+
180
+ // Call callback if provided
181
+ if (this.onProgressCallback) {
182
+ this.onProgressCallback(progressInfo);
183
+ }
184
+ }
185
+ }
186
+ }
187
+
188
+ /**
189
+ * Create a progress reporter with callback
190
+ * @param {number} total - Total number of items
191
+ * @param {Object} options - Options (see ProgressReporter constructor)
192
+ * @returns {ProgressReporter} Progress reporter instance
193
+ */
194
+ export function createProgressReporter(total, options = {}) {
195
+ return new ProgressReporter(total, options);
196
+ }
197
+
198
+ /**
199
+ * Format progress as a simple text string
200
+ * @param {ProgressInfo} progress - Progress information
201
+ * @returns {string} Formatted progress string
202
+ */
203
+ export function formatProgress(progress) {
204
+ const { current, total, percentage, elapsed, estimated, stage } = progress;
205
+
206
+ const elapsedSec = (elapsed / 1000).toFixed(1);
207
+ const estimatedSec = estimated > 0 ? (estimated / 1000).toFixed(1) : '?';
208
+
209
+ let text = `[${stage}] ${current}/${total} (${percentage}%)`;
210
+
211
+ if (elapsed > 0) {
212
+ text += ` | Elapsed: ${elapsedSec}s`;
213
+ if (estimated > 0) {
214
+ text += ` | ETA: ${estimatedSec}s`;
215
+ }
216
+ }
217
+
218
+ if (progress.memory) {
219
+ text += ` | Memory: ${progress.memory.heapUsed.toFixed(1)}MB`;
220
+ }
221
+
222
+ return text;
223
+ }
224
+
225
+ /**
226
+ * Create a simple progress bar string
227
+ * @param {ProgressInfo} progress - Progress information
228
+ * @param {number} width - Bar width in characters (default: 40)
229
+ * @returns {string} Progress bar string
230
+ */
231
+ export function createProgressBar(progress, width = 40) {
232
+ const { percentage } = progress;
233
+ const filled = Math.round((percentage / 100) * width);
234
+ const empty = width - filled;
235
+
236
+ const bar = '█'.repeat(filled) + '░'.repeat(empty);
237
+ return `[${bar}] ${percentage}%`;
238
+ }
239
+
240
+ export default {
241
+ ProgressReporter,
242
+ createProgressReporter,
243
+ formatProgress,
244
+ createProgressBar
245
+ };