@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.
- package/LICENSE +201 -0
- package/README.md +71 -0
- package/bin/cli.js +1577 -0
- package/package.json +103 -0
- package/src/audit-config.js +331 -0
- package/src/audit.js +805 -0
- package/src/backup.js +235 -0
- package/src/checker.js +771 -0
- package/src/checksum-fixer.js +419 -0
- package/src/deprecation.js +325 -0
- package/src/fixer.js +207 -0
- package/src/format-library.js +132 -0
- package/src/index.js +138 -0
- package/src/integrity.js +519 -0
- package/src/migrator.js +263 -0
- package/src/npmrc-validator.js +205 -0
- package/src/overrides.js +71 -0
- package/src/package-json-validator.js +356 -0
- package/src/parallel-processor.js +374 -0
- package/src/parser.js +133 -0
- package/src/performance.js +283 -0
- package/src/pinner.js +248 -0
- package/src/pnpm-format.js +229 -0
- package/src/pnpm-workspace-validator.js +121 -0
- package/src/progress-reporter.js +245 -0
- package/src/pruner.js +177 -0
- package/src/remediate.js +389 -0
- package/src/report.js +663 -0
- package/src/schema.js +76 -0
- package/src/streaming-parser.js +251 -0
- package/src/updater.js +251 -0
- package/src/usage-scanner.js +215 -0
- package/src/validator.js +282 -0
- package/src/vuln.js +618 -0
- package/src/workers/dedupe-worker.js +20 -0
- package/src/workers/hash-upgrade-worker.js +20 -0
- package/src/workers/migration-worker.js +21 -0
- package/src/workers/validation-worker.js +20 -0
|
@@ -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
|
+
};
|