@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/src/schema.js ADDED
@@ -0,0 +1,76 @@
1
+ // src/schema.js
2
+ // The Dependably suite's shared finding JSON schema (v1). Every tool's
3
+ // `--format json` emits the SAME top-level envelope so an AI or CI consumer can
4
+ // parse any of the five tools identically; tool-specific data only ever rides
5
+ // under `extra` (never as a new top-level key). See the suite schema spec.
6
+ import fs from 'fs';
7
+ import path from 'path';
8
+ import { fileURLToPath } from 'url';
9
+
10
+ export const TOOL_NAME = 'npm-check';
11
+ export const SCHEMA_VERSION = '1.0';
12
+
13
+ // The ONE severity ladder, most-severe first.
14
+ export const SEVERITY_LADDER = ['critical', 'high', 'moderate', 'low', 'info'];
15
+
16
+ let cachedVersion = null;
17
+
18
+ // Resolve the tool's own semver from package.json (cached). Mirrors bin/cli.js's
19
+ // getVersion so the envelope's toolVersion always matches `--version`.
20
+ export function toolVersion() {
21
+ if (cachedVersion) return cachedVersion;
22
+ try {
23
+ const here = path.dirname(fileURLToPath(import.meta.url));
24
+ const pkg = JSON.parse(fs.readFileSync(path.join(here, '../package.json'), 'utf8'));
25
+ cachedVersion = pkg.version || 'unknown';
26
+ } catch {
27
+ cachedVersion = 'unknown';
28
+ }
29
+ return cachedVersion;
30
+ }
31
+
32
+ // A zeroed bySeverity histogram with every ladder key present.
33
+ export function emptyBySeverity() {
34
+ return { critical: 0, high: 0, moderate: 0, low: 0, info: 0 };
35
+ }
36
+
37
+ // Count findings into the ladder histogram (ignores any off-ladder severity).
38
+ export function tallyBySeverity(findings) {
39
+ const by = emptyBySeverity();
40
+ for (const f of findings) {
41
+ if (f && Object.prototype.hasOwnProperty.call(by, f.severity)) by[f.severity]++;
42
+ }
43
+ return by;
44
+ }
45
+
46
+ /**
47
+ * Assemble the shared envelope. `findings` MUST be the complete, schema-conformant
48
+ * list (never truncated); `summary.findings` is derived from it and `summary.exitCode`
49
+ * MUST equal the process exit code the run will return.
50
+ *
51
+ * @param {object} args
52
+ * @param {string} args.target - path/manifest scanned, as given
53
+ * @param {number} args.scanned - packages examined
54
+ * @param {Array} args.findings - schema Finding objects
55
+ * @param {number} args.exitCode - the real process exit code (0/1/2)
56
+ * @param {object} [args.extra] - the ONE sanctioned tool-specific escape hatch
57
+ * @returns {object} the envelope
58
+ */
59
+ export function buildEnvelope({ target, scanned, findings, exitCode, extra }) {
60
+ const list = Array.isArray(findings) ? findings : [];
61
+ const envelope = {
62
+ tool: TOOL_NAME,
63
+ toolVersion: toolVersion(),
64
+ schemaVersion: SCHEMA_VERSION,
65
+ target,
66
+ summary: {
67
+ scanned,
68
+ findings: list.length,
69
+ bySeverity: tallyBySeverity(list),
70
+ exitCode
71
+ },
72
+ findings: list
73
+ };
74
+ if (extra !== undefined) envelope.extra = extra;
75
+ return envelope;
76
+ }
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Buffered parser for large package-lock.json files.
3
+ *
4
+ * NOTE: despite the "streaming" name, these helpers read the file in chunks (so
5
+ * progress can be reported) but parse the fully-buffered content with the shared
6
+ * `parseLockfile` from format-library — they do NOT parse incrementally. A true
7
+ * incremental JSON parser is future work. The buffered approach is honest about
8
+ * its result: it returns the actual parsed lockfile (or throws), never an empty
9
+ * skeleton that would make every downstream integrity/vuln/license check pass
10
+ * vacuously.
11
+ */
12
+
13
+ import fs from 'fs';
14
+ import { EventEmitter } from 'events';
15
+ import { parseLockfile as parseLockfileFromFormat } from './format-library.js';
16
+
17
+ // Record one incremental `package` event into the accumulating lockfile: the
18
+ // root ('') merges into rootMetadata, others land in the packages map.
19
+ function recordStreamPackage(path, pkg, lockfile, rootMetadata, options) {
20
+ if (path === '') {
21
+ Object.assign(rootMetadata, pkg); // root package → root metadata
22
+ } else {
23
+ lockfile.packages[path] = pkg;
24
+ }
25
+ if (options.onPackage) options.onPackage(path, pkg);
26
+ }
27
+
28
+ // Assemble the final result from the buffered `complete` event. The parsed result
29
+ // is authoritative; anything the (currently no-op) incremental events collected is
30
+ // layered on WITHOUT injecting empty skeleton keys — a v3 file must not gain a
31
+ // spurious `dependencies: {}`, nor a v1 file a `packages: {}`, which a write-back
32
+ // would then persist.
33
+ function assembleStreamResult(result, lockfile, rootMetadata) {
34
+ const parsed = (result && typeof result === 'object') ? result : {};
35
+ const merged = { ...parsed };
36
+ for (const [k, v] of Object.entries(rootMetadata)) {
37
+ if (!(k in merged)) merged[k] = v;
38
+ }
39
+ if (Object.keys(lockfile.packages).length > 0) {
40
+ merged.packages = { ...(parsed.packages || {}), ...lockfile.packages };
41
+ }
42
+ return merged;
43
+ }
44
+
45
+ /**
46
+ * Streaming parser for package-lock.json files
47
+ * Handles large files by parsing incrementally
48
+ */
49
+ export class StreamingParser extends EventEmitter {
50
+ /**
51
+ * Create a streaming parser
52
+ * @param {Object} options - Parser options
53
+ * @param {Function} options.onPackage - Callback when package is parsed (path, pkg)
54
+ * @param {Function} options.onProgress - Progress callback (bytesRead, totalBytes)
55
+ * @param {number} options.chunkSize - Read chunk size in bytes (default: 64KB)
56
+ */
57
+ constructor(options = {}) {
58
+ super();
59
+ this.onPackageCallback = options.onPackage || null;
60
+ this.onProgressCallback = options.onProgress || null;
61
+ this.chunkSize = options.chunkSize || 64 * 1024;
62
+ this.buffer = '';
63
+ this.state = 'initial';
64
+ this.depth = 0;
65
+ this.currentPath = null;
66
+ this.currentPackage = null;
67
+ this.inString = false;
68
+ this.escapeNext = false;
69
+ this.bytesRead = 0;
70
+ this.totalBytes = 0;
71
+ }
72
+
73
+ /**
74
+ * Parse a lockfile from file path using streaming
75
+ * @param {string} filePath - Path to lockfile
76
+ * @param {Object} options - Parser options
77
+ * @returns {Promise<Object>} Parsed lockfile object
78
+ */
79
+ static async parseLockfileStream(filePath, options = {}) {
80
+ const stats = fs.statSync(filePath);
81
+ const totalBytes = stats.size;
82
+
83
+ const parser = new StreamingParser({
84
+ ...options,
85
+ onProgress: (bytesRead) => {
86
+ if (options.onProgress) {
87
+ options.onProgress(bytesRead, totalBytes);
88
+ }
89
+ }
90
+ });
91
+
92
+ parser.totalBytes = totalBytes;
93
+
94
+ return new Promise((resolve, reject) => {
95
+ const lockfile = {
96
+ packages: {},
97
+ dependencies: {}
98
+ };
99
+
100
+ // Collect root metadata
101
+ let rootMetadata = {};
102
+
103
+ parser.on('package', (path, pkg) => recordStreamPackage(path, pkg, lockfile, rootMetadata, options));
104
+ parser.on('metadata', (key, value) => {
105
+ rootMetadata[key] = value;
106
+ });
107
+ parser.on('error', reject);
108
+ parser.on('complete', (result) => resolve(assembleStreamResult(result, lockfile, rootMetadata)));
109
+
110
+ const stream = fs.createReadStream(filePath, {
111
+ encoding: 'utf8',
112
+ highWaterMark: parser.chunkSize
113
+ });
114
+
115
+ stream.on('data', (chunk) => {
116
+ parser.processChunk(chunk);
117
+ });
118
+
119
+ stream.on('end', () => {
120
+ parser.finish();
121
+ });
122
+
123
+ stream.on('error', reject);
124
+ });
125
+ }
126
+
127
+ /**
128
+ * Process a chunk of data
129
+ * @param {string} chunk - Data chunk
130
+ */
131
+ processChunk(chunk) {
132
+ this.buffer += chunk;
133
+ this.bytesRead += Buffer.byteLength(chunk, 'utf8');
134
+
135
+ if (this.onProgressCallback) {
136
+ this.onProgressCallback(this.bytesRead);
137
+ }
138
+
139
+ // Simple approach: For very large files, fall back to standard parsing
140
+ // but do it in a way that doesn't block. For now, we'll use a hybrid approach.
141
+ // If buffer gets too large, parse what we have and continue.
142
+
143
+ // For package-lock.json, the structure is predictable enough that we can
144
+ // use a simpler incremental approach: read the file in chunks and parse
145
+ // the packages map incrementally.
146
+
147
+ // Since full streaming JSON parsing is complex, we'll use a pragmatic approach:
148
+ // For files under a certain size, use standard parsing.
149
+ // For larger files, we'll implement a simplified streaming parser that
150
+ // extracts packages one by one.
151
+ }
152
+
153
+ /**
154
+ * Finish parsing
155
+ */
156
+ finish() {
157
+ if (this.buffer.trim()) {
158
+ try {
159
+ // Parse the fully-buffered content through the shared format-library
160
+ // parser (consistent error messages; not raw JSON.parse).
161
+ const parsed = parseLockfileFromFormat(this.buffer);
162
+ this.emit('complete', parsed);
163
+ } catch (error) {
164
+ this.emit('error', error);
165
+ }
166
+ } else {
167
+ // An empty stream is not a valid lockfile. Fail loudly rather than
168
+ // resolving an empty (vacuously "clean") lockfile.
169
+ this.emit('error', new Error('Cannot parse lockfile: stream produced no data'));
170
+ }
171
+ }
172
+ }
173
+
174
+ /**
175
+ * Parse lockfile using streaming approach for large files
176
+ * Falls back to standard parsing for smaller files
177
+ * @param {string} filePath - Path to lockfile
178
+ * @param {Object} options - Options
179
+ * @param {Function} options.onPackage - Callback when package parsed
180
+ * @param {Function} options.onProgress - Progress callback
181
+ * @param {number} options.streamingThreshold - File size threshold in bytes for streaming (default: 10MB)
182
+ * @returns {Promise<Object>} Parsed lockfile
183
+ */
184
+ export async function parseLockfileStream(filePath, options = {}) {
185
+ const stats = fs.statSync(filePath);
186
+ const fileSize = stats.size;
187
+ const threshold = options.streamingThreshold || 10 * 1024 * 1024; // 10MB default
188
+
189
+ // For files smaller than threshold, use standard parsing
190
+ if (fileSize < threshold) {
191
+ const content = fs.readFileSync(filePath, 'utf8');
192
+ return parseLockfileFromFormat(content);
193
+ }
194
+
195
+ // For larger files, read the file in chunks (so we can report progress) and then
196
+ // parse the fully-buffered content. This is buffered, not truly incremental, but
197
+ // it returns the ACTUAL parsed lockfile (or rejects) — never an empty skeleton.
198
+ return new Promise((resolve, reject) => {
199
+ const stream = fs.createReadStream(filePath, { encoding: 'utf8' });
200
+ let buffer = '';
201
+ let bytesRead = 0;
202
+
203
+ stream.on('data', (chunk) => {
204
+ buffer += chunk;
205
+ bytesRead += Buffer.byteLength(chunk, 'utf8');
206
+
207
+ if (options.onProgress) {
208
+ options.onProgress(bytesRead, fileSize);
209
+ }
210
+ });
211
+
212
+ stream.on('end', () => {
213
+ try {
214
+ resolve(parseLockfileFromFormat(buffer));
215
+ } catch (error) {
216
+ reject(error);
217
+ }
218
+ });
219
+
220
+ stream.on('error', reject);
221
+ });
222
+ }
223
+
224
+ /**
225
+ * Synchronous version with callbacks (for compatibility)
226
+ * @param {string} filePath - Path to lockfile
227
+ * @param {Object} options - Options
228
+ * @returns {Object} Parsed lockfile
229
+ */
230
+ export function parseLockfileStreamSync(filePath, options = {}) {
231
+ const stats = fs.statSync(filePath);
232
+ const fileSize = stats.size;
233
+ const threshold = options.streamingThreshold || 10 * 1024 * 1024;
234
+
235
+ // For smaller files, use standard parsing
236
+ if (fileSize < threshold) {
237
+ const content = fs.readFileSync(filePath, 'utf8');
238
+ return parseLockfileFromFormat(content);
239
+ }
240
+
241
+ // For larger files, read in chunks but still parse at once
242
+ // This is a compromise - true streaming would require async or more complex parsing
243
+ const content = fs.readFileSync(filePath, 'utf8');
244
+ return parseLockfileFromFormat(content);
245
+ }
246
+
247
+ export default {
248
+ StreamingParser,
249
+ parseLockfileStream,
250
+ parseLockfileStreamSync
251
+ };
package/src/updater.js ADDED
@@ -0,0 +1,251 @@
1
+ /**
2
+ * Updater module for package-lock.json files
3
+ * Optimized implementations for handling large lockfiles efficiently.
4
+ */
5
+
6
+ import { detectLockfileVersion, hasPackagesMap, resolvePackageName } from './format-library.js';
7
+ import {
8
+ shallowCopyLockfile,
9
+ filterPackagesLazy,
10
+ isLargeLockfile
11
+ } from './performance.js';
12
+ import { parallelUpgradeIntegrityHashes as parallelUpgrade, parallelDeduplicatePackages as parallelDedupe } from './parallel-processor.js';
13
+
14
+ // Dependency sections within a package entry whose integrity hashes are
15
+ // upgraded recursively alongside the entry's own integrity.
16
+ const DEPENDENCY_SECTIONS = ['dependencies', 'devDependencies', 'peerDependencies', 'optionalDependencies'];
17
+
18
+ // Emit a progress update for the integrity-hash upgrade stage.
19
+ function reportUpgradeProgress(onProgress, processed, total) {
20
+ if (!onProgress) return;
21
+ onProgress({ current: processed, total, percentage: Math.round((processed / total) * 100), stage: 'Upgrading integrity hashes' });
22
+ }
23
+
24
+ // Promote a single integrity hash to sha512. sha1 hashes are always promoted;
25
+ // other non-sha512 hashes are promoted only when `all` is set.
26
+ function upgradeHash(hash, all) {
27
+ if (!hash) return hash;
28
+ if (hash.startsWith('sha1-')) {
29
+ return 'sha512-' + hash.slice(5);
30
+ }
31
+ if (all && !hash.startsWith('sha512-')) {
32
+ return 'sha512-' + hash.slice(hash.indexOf('-') + 1);
33
+ }
34
+ return hash;
35
+ }
36
+
37
+ // Recursively upgrade integrity hashes in a dependency tree, preserving
38
+ // non-object entries untouched.
39
+ function upgradeDependencies(deps, all) {
40
+ if (!deps || typeof deps !== 'object') return deps;
41
+
42
+ const upgraded = {};
43
+ for (const [depName, dep] of Object.entries(deps)) {
44
+ if (!dep || typeof dep !== 'object') {
45
+ upgraded[depName] = dep;
46
+ continue;
47
+ }
48
+
49
+ // Upgrade this dependency and recursively upgrade nested ones
50
+ upgraded[depName] = {
51
+ ...dep,
52
+ ...(dep.integrity && { integrity: upgradeHash(dep.integrity, all) }),
53
+ ...(dep.dependencies && { dependencies: upgradeDependencies(dep.dependencies, all) })
54
+ };
55
+ }
56
+ return upgraded;
57
+ }
58
+
59
+ // Upgrade a single package entry's own integrity plus each of its nested
60
+ // dependency sections, returning a shallow copy.
61
+ function upgradePackageEntry(pkg, all) {
62
+ const upgradedPkg = { ...pkg };
63
+
64
+ if (upgradedPkg.integrity) {
65
+ upgradedPkg.integrity = upgradeHash(upgradedPkg.integrity, all);
66
+ }
67
+
68
+ for (const section of DEPENDENCY_SECTIONS) {
69
+ if (upgradedPkg[section] && typeof upgradedPkg[section] === 'object') {
70
+ upgradedPkg[section] = upgradeDependencies(upgradedPkg[section], all);
71
+ }
72
+ }
73
+
74
+ return upgradedPkg;
75
+ }
76
+
77
+ /**
78
+ * Upgrade integrity hashes in a lockfile
79
+ * @param {object} lockfileData - Lockfile data
80
+ * @param {object} options - Options for upgrade
81
+ * @param {boolean} options.all - Upgrade all hashes (default: false, only sha1)
82
+ * @param {Function} options.onProgress - Progress callback function(progressInfo)
83
+ * @param {boolean} options.parallel - Use parallel processing for large files (default: false)
84
+ * @returns {object|Promise<object>} Updated lockfile data (immutable - returns Promise if parallel)
85
+ */
86
+ export function upgradeIntegrityHashes(lockfileData, options = {}) {
87
+ const { all = false, onProgress = null, parallel = false } = options;
88
+
89
+ // Use parallel processing if requested and file is large
90
+ if (parallel && isLargeLockfile(lockfileData, 10)) {
91
+ return parallelUpgrade(lockfileData, {
92
+ all,
93
+ onProgress,
94
+ ...options
95
+ });
96
+ }
97
+ const version = detectLockfileVersion(lockfileData);
98
+
99
+ if (!hasPackagesMap(version)) {
100
+ return lockfileData;
101
+ }
102
+
103
+ // Use shallow copy to avoid deep cloning
104
+ const result = shallowCopyLockfile(lockfileData);
105
+
106
+ // Process packages directly without deep recursion
107
+ const packageEntries = Object.entries(result.packages || {});
108
+ const total = packageEntries.length;
109
+ const upgradedPackages = {};
110
+ let processed = 0;
111
+
112
+ for (const [path, pkg] of packageEntries) {
113
+ upgradedPackages[path] = pkg ? upgradePackageEntry(pkg, all) : pkg;
114
+ processed++;
115
+
116
+ // Null entries report every step; real entries report every 100th.
117
+ if (!pkg || processed % 100 === 0) {
118
+ reportUpgradeProgress(onProgress, processed, total);
119
+ }
120
+ }
121
+
122
+ result.packages = upgradedPackages;
123
+ return result;
124
+ }
125
+
126
+ /**
127
+ * Deduplicate packages in a lockfile
128
+ * Uses Map-based deduplication for O(1) lookups
129
+ * @param {object} lockfileData - Lockfile data
130
+ * @param {object} options - Options for deduplication
131
+ * @param {boolean} options.keepLatest - Keep only latest version of duplicates (default: false)
132
+ * @param {Function} options.onProgress - Progress callback function(progressInfo)
133
+ * @param {boolean} options.parallel - Use parallel processing for large files (default: false)
134
+ * @returns {object|Promise<object>} Updated lockfile data (immutable - returns Promise if parallel)
135
+ */
136
+ export function deduplicatePackages(lockfileData, options = {}) {
137
+ const { keepLatest = false, onProgress = null, parallel = false } = options;
138
+
139
+ // Use parallel processing if requested and file is large
140
+ if (parallel && isLargeLockfile(lockfileData, 10)) {
141
+ return parallelDedupe(lockfileData, {
142
+ keepLatest,
143
+ onProgress,
144
+ ...options
145
+ });
146
+ }
147
+ const version = detectLockfileVersion(lockfileData);
148
+
149
+ const result = shallowCopyLockfile(lockfileData);
150
+
151
+ // Packages map (v2/v3): keyed by *install path*. Every entry is a distinct,
152
+ // required node — the package name is encoded in the path, so most entries
153
+ // carry no `.name` field at all. There is no safe way to "remove duplicates"
154
+ // here: two entries that share a name@version live at different paths because
155
+ // npm could not hoist them to one location (conflicting dependents), and
156
+ // collapsing them produces an un-installable lockfile. Real npm deduplication
157
+ // is tree hoisting, which requires full re-resolution and is out of scope.
158
+ //
159
+ // So we PRESERVE every path entry. (The previous implementation keyed a map by
160
+ // `name#version` and rebuilt from it, which silently dropped every entry that
161
+ // lacked a `.name` field — i.e. effectively the entire packages map — gutting
162
+ // the lockfile down to the root. See deduplicatePackages preserve tests.)
163
+ // Use `prune` to remove genuinely orphaned (unreachable) entries.
164
+ if (hasPackagesMap(version) && result.packages) {
165
+ const total = Object.keys(result.packages).length;
166
+ if (onProgress) {
167
+ onProgress({ current: total, total, percentage: 100, stage: 'Deduplication complete' });
168
+ }
169
+ }
170
+
171
+ // v1 dependencies tree: top-level keys are unique by JS object construction,
172
+ // so a Set-based filter at this level is a no-op. Real v1 "deduplication" is
173
+ // tree hoisting from nested sub-dependency objects — that requires full
174
+ // re-resolution and is out of scope here (use `npm install --prefer-dedupe`).
175
+ // We preserve the tree as-is, consistent with the packages-map preserve-only
176
+ // policy above. No entries are removed.
177
+
178
+ return result;
179
+ }
180
+
181
+ /**
182
+ * Find packages matching a predicate without full copy (lazy evaluation)
183
+ * Useful for filtering operations on large lockfiles
184
+ * @param {object} lockfileData - Lockfile data
185
+ * @param {Function} predicate - Function(path, pkg) => boolean
186
+ * @returns {object} Filtered packages object
187
+ */
188
+ export function findPackagesMatching(lockfileData, predicate) {
189
+ return filterPackagesLazy(lockfileData.packages || {}, predicate);
190
+ }
191
+
192
+ /**
193
+ * Count unique packages in lockfile (efficient for large files)
194
+ * @param {object} lockfileData - Lockfile data
195
+ * @returns {number} Number of unique packages
196
+ */
197
+ export function countUniquePackages(lockfileData) {
198
+ if (!lockfileData.packages || typeof lockfileData.packages !== 'object') {
199
+ return 0;
200
+ }
201
+
202
+ const uniqueNames = new Set();
203
+ for (const [path, pkg] of Object.entries(lockfileData.packages)) {
204
+ if (!pkg || typeof pkg !== 'object') continue;
205
+ // Derive the name from the path — v2/v3 entries usually have no `.name` field,
206
+ // so reading `pkg.name` alone would miss nearly every real dependency.
207
+ const name = resolvePackageName(path, pkg);
208
+ if (name && name !== '(root)') uniqueNames.add(name);
209
+ }
210
+
211
+ return uniqueNames.size;
212
+ }
213
+
214
+ /**
215
+ * Find duplicate packages efficiently
216
+ * @param {object} lockfileData - Lockfile data
217
+ * @returns {Map} Map of package names to array of {path, version} objects
218
+ */
219
+ export function findDuplicatePackages(lockfileData) {
220
+ const duplicates = new Map();
221
+
222
+ for (const [path, pkg] of Object.entries(lockfileData.packages || {})) {
223
+ if (!pkg || typeof pkg !== 'object') continue;
224
+ // Derive the name from the path — v2/v3 entries usually have no `.name` field.
225
+ const name = resolvePackageName(path, pkg);
226
+ if (!name || name === '(root)') continue;
227
+
228
+ if (!duplicates.has(name)) {
229
+ duplicates.set(name, []);
230
+ }
231
+
232
+ duplicates.get(name).push({ path, version: pkg.version });
233
+ }
234
+
235
+ // Keep only actual duplicates
236
+ for (const [name, entries] of duplicates.entries()) {
237
+ if (entries.length === 1) {
238
+ duplicates.delete(name);
239
+ }
240
+ }
241
+
242
+ return duplicates;
243
+ }
244
+
245
+ export default {
246
+ upgradeIntegrityHashes,
247
+ deduplicatePackages,
248
+ findPackagesMatching,
249
+ countUniquePackages,
250
+ findDuplicatePackages
251
+ };