@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/report.js ADDED
@@ -0,0 +1,663 @@
1
+ // src/report.js
2
+ // Unified report: runs every check npm-check offers (the audit rules covering
3
+ // lockfile + package.json + .npmrc validation, plus registry integrity
4
+ // verification and license validation) and renders one clean, sectioned report.
5
+ import fs from 'fs';
6
+ import path from 'path';
7
+ import { runAudit, classifyInstallScripts } from './audit.js';
8
+ import { mergeConfig } from './audit-config.js';
9
+ import { checkIntegrity, checkLicenses } from './checker.js';
10
+ import { checkVulnerabilities } from './vuln.js';
11
+ import { checkDeprecations } from './deprecation.js';
12
+ import { detectLockfileFlavor } from './format-library.js';
13
+ import { buildEnvelope } from './schema.js';
14
+
15
+ // Sections that apply to a pnpm lockfile: the registry-backed scans, the config
16
+ // validators (package.json, .npmrc, pnpm-workspace.yaml + pnpm field), and
17
+ // pinned-versions (a manifest-level check — pnpm dep ranges and pnpm.overrides
18
+ // pin just like npm's, and the pinned-versions rule is npm+pnpm flavored). The
19
+ // npm-lockfile-shape sections (and license, pending a `.pnpm` store walk) are
20
+ // marked N/A rather than rendered as a misleading pass.
21
+ const PNPM_LIVE_SECTIONS = new Set(['integrity', 'vuln', 'deprecated', 'package-json', 'npmrc', 'pnpm-config', 'pinned']);
22
+ // The pnpm-config section has no meaning for an npm lockfile.
23
+ const NPM_NA_SECTIONS = new Set(['pnpm-config']);
24
+
25
+ export class ReportError extends Error {
26
+ constructor(message, code, context = {}) {
27
+ super(message);
28
+ this.name = 'ReportError';
29
+ this.code = code;
30
+ this.context = context;
31
+ }
32
+ }
33
+
34
+ // Which report section each audit rule feeds into.
35
+ const RULE_SECTION = {
36
+ 'lockfile-version': 'structure',
37
+ 'valid-structure': 'structure',
38
+ 'lockfile-sync': 'structure',
39
+ 'valid-package-json': 'package-json',
40
+ 'valid-npmrc': 'npmrc',
41
+ 'integrity-hygiene': 'integrity',
42
+ 'secure-resolved': 'resolved',
43
+ 'install-scripts': 'install-scripts',
44
+ 'no-git-deps': 'git',
45
+ 'no-remote-deps': 'remote',
46
+ 'pinned-versions': 'pinned',
47
+ 'no-orphan-packages': 'orphans',
48
+ 'unused-dependencies': 'unused',
49
+ 'no-fund': 'fund',
50
+ 'valid-pnpm-workspace': 'pnpm-config',
51
+ 'valid-pnpm-field': 'pnpm-config'
52
+ };
53
+
54
+ // Display order and titles for the sections.
55
+ const SECTIONS = [
56
+ { id: 'structure', title: 'Structure & format' },
57
+ { id: 'package-json', title: 'package.json' },
58
+ { id: 'npmrc', title: '.npmrc (config)' },
59
+ { id: 'pnpm-config', title: 'pnpm (workspace + manifest)' },
60
+ { id: 'integrity', title: 'Integrity (registry)' },
61
+ { id: 'vuln', title: 'Known vulnerabilities' },
62
+ { id: 'deprecated', title: 'Deprecated packages' },
63
+ { id: 'resolved', title: 'Resolved URLs' },
64
+ { id: 'licenses', title: 'Licenses' },
65
+ { id: 'install-scripts', title: 'Install scripts' },
66
+ { id: 'git', title: 'Git dependencies' },
67
+ { id: 'remote', title: 'Remote-URL deps' },
68
+ { id: 'pinned', title: 'Pinned versions' },
69
+ { id: 'orphans', title: 'Orphaned packages' },
70
+ { id: 'unused', title: 'Unused dependencies' },
71
+ { id: 'fund', title: 'Funding solicitations' }
72
+ ];
73
+
74
+ const MAX_DETAIL = 50; // cap per-section detail lines so the report stays readable
75
+
76
+ function worstSeverity(findings) {
77
+ if (findings.some((f) => f.severity === 'error')) return 'error';
78
+ if (findings.some((f) => f.severity === 'warn')) return 'warn';
79
+ return null;
80
+ }
81
+
82
+ // Append a normalized finding to its section bucket, creating it on first use.
83
+ function pushFinding(buckets, id, finding) {
84
+ const list = buckets[id] || (buckets[id] = []);
85
+ list.push(finding);
86
+ }
87
+
88
+ // Bucket integrity findings: real hash mismatches are errors. Unresolved entries
89
+ // (could-not-verify) are errors when failing closed (the default), else warnings.
90
+ function collectIntegrityFindings(buckets, integrityResult, failOnUnresolved) {
91
+ for (const err of integrityResult.errors) {
92
+ if (err.expected && err.actual) {
93
+ pushFinding(buckets, 'integrity', { severity: 'error', location: err.packagePath, message: `lockfile hash differs from registry for ${err.package}` });
94
+ }
95
+ }
96
+ const unresolvedSeverity = failOnUnresolved ? 'error' : 'warn';
97
+ for (const item of integrityResult.unresolvedItems) {
98
+ pushFinding(buckets, 'integrity', { severity: unresolvedSeverity, location: item.packagePath, message: `${item.package}@${item.version}: ${item.reason}` });
99
+ }
100
+ }
101
+
102
+ // Coerce an advisory's references into an array: an explicit `references` array
103
+ // wins, else the single `url` (if any) becomes a one-element list, else empty.
104
+ function referencesOf(f) {
105
+ if (Array.isArray(f.references)) return f.references;
106
+ return f.url ? [f.url] : [];
107
+ }
108
+
109
+ // Normalize one advisory finding (from vuln.js's errors/warnings) into a report
110
+ // finding that PRESERVES the full advisory data instead of collapsing it. `level`
111
+ // is the report-tier severity (error|warn) that drives the icons, the section
112
+ // status and the pass/fail rollup; `advisorySeverity` carries the TRUE 5-level
113
+ // advisory severity (info|low|moderate|high|critical) so JSON consumers no longer
114
+ // have to scrape it out of the message string. Mirrors `vuln --format json`.
115
+ function advisoryFinding(level, f) {
116
+ return {
117
+ severity: level,
118
+ location: f.packagePath,
119
+ message: `${f.package}@${f.version}: ${f.title} (${f.severity})`,
120
+ package: f.package,
121
+ version: f.version,
122
+ advisoryId: f.advisoryId,
123
+ title: f.title,
124
+ advisorySeverity: f.severity,
125
+ fixedVersion: f.fixedVersion ?? null,
126
+ cve: f.cve ?? null,
127
+ vulnerableRange: f.vulnerableRange ?? null,
128
+ references: referencesOf(f),
129
+ url: f.url ?? null
130
+ };
131
+ }
132
+
133
+ // Bucket vulnerability findings. Advisory findings are errors. Unresolved entries —
134
+ // packages the scan could not check at all — are errors when failing closed (the
135
+ // default), else warnings; rendered once here (not from `errors`, where they have no advisoryId).
136
+ function collectVulnFindings(buckets, vulnResult, failOnUnresolved) {
137
+ for (const err of vulnResult.errors) {
138
+ // Discriminate on `reason` (like vulnEnvelope), NOT on `advisoryId`: an
139
+ // unresolved entry carries a `reason` and is rendered from unresolvedItems
140
+ // below, while a genuine advisory has none — including one that merely lacks
141
+ // an `id`, which must still fail the run rather than silently vanish.
142
+ if (err.reason) continue;
143
+ pushFinding(buckets, 'vuln', advisoryFinding('error', err));
144
+ }
145
+ for (const warn of vulnResult.warnings) {
146
+ pushFinding(buckets, 'vuln', advisoryFinding('warn', warn));
147
+ }
148
+ const unresolvedSeverity = failOnUnresolved ? 'error' : 'warn';
149
+ for (const item of vulnResult.unresolvedItems) {
150
+ pushFinding(buckets, 'vuln', { severity: unresolvedSeverity, location: item.packagePath, message: `could not scan ${item.package}@${item.version}: ${item.reason}` });
151
+ }
152
+ }
153
+
154
+ // Bucket deprecation findings. A *found* deprecation is an error only under
155
+ // failOnDeprecated (it lands in `errors` with a message), else a warning. Unresolved
156
+ // entries — the scan couldn't complete — are errors when failing closed (the default),
157
+ // else warnings; rendered once here (those in `errors` carry no `message`).
158
+ function collectDeprecationFindings(buckets, deprecationResult, failOnUnresolved) {
159
+ for (const err of deprecationResult.errors) {
160
+ if (!err.message) continue;
161
+ pushFinding(buckets, 'deprecated', { severity: 'error', location: err.packagePath, message: `${err.package}@${err.version}: ${err.message}` });
162
+ }
163
+ for (const warn of deprecationResult.warnings) {
164
+ pushFinding(buckets, 'deprecated', { severity: 'warn', location: warn.packagePath, message: `${warn.package}@${warn.version}: ${warn.message}` });
165
+ }
166
+ const unresolvedSeverity = failOnUnresolved ? 'error' : 'warn';
167
+ for (const item of deprecationResult.unresolvedItems) {
168
+ pushFinding(buckets, 'deprecated', { severity: unresolvedSeverity, location: item.packagePath, message: `could not scan ${item.package}@${item.version}: ${item.reason}` });
169
+ }
170
+ }
171
+
172
+ // Bucket license findings: rejected licenses are errors, unknown licenses warn.
173
+ function collectLicenseFindings(buckets, licenseResult) {
174
+ for (const err of licenseResult.errors) {
175
+ pushFinding(buckets, 'licenses', { severity: 'error', location: err.package, message: `license "${err.license || 'UNKNOWN'}" not approved` });
176
+ }
177
+ for (const warn of licenseResult.warnings) {
178
+ pushFinding(buckets, 'licenses', { severity: 'warn', location: warn.package, message: `license "${warn.license || 'UNKNOWN'}" unknown` });
179
+ }
180
+ }
181
+
182
+ // One-line summary for the integrity section's count bits.
183
+ function integritySummary(r) {
184
+ const bits = [`${r.passed} verified`];
185
+ if (r.failed) bits.push(`${r.failed} mismatched`);
186
+ if (r.unresolved) bits.push(`${r.unresolved} unresolved`);
187
+ if (r.skipped) bits.push(`${r.skipped} skipped`);
188
+ return bits.join(' · ');
189
+ }
190
+
191
+ // One-line summary shared by the vuln and deprecation sections (scanned/flagged/…).
192
+ function scanSummary(r, flaggedKey, flaggedLabel) {
193
+ const bits = [`${r.scanned} scanned`];
194
+ if (r[flaggedKey]) bits.push(`${r[flaggedKey]} ${flaggedLabel}`);
195
+ if (r.unresolved) bits.push(`${r.unresolved} unresolved`);
196
+ if (r.skipped) bits.push(`${r.skipped} skipped`);
197
+ return bits.join(' · ');
198
+ }
199
+
200
+ // One-line summary for the license section's count bits.
201
+ function licenseSummary(r) {
202
+ const bits = [`${r.approved} ok`];
203
+ if (r.rejected) bits.push(`${r.rejected} rejected`);
204
+ if (r.unknown) bits.push(`${r.unknown} unknown`);
205
+ return bits.join(' · ');
206
+ }
207
+
208
+ // One-line summary for the install-scripts section, reconciled against allowScripts.
209
+ function installScriptsSummary(tally) {
210
+ if (tally.total === 0) return 'none';
211
+ if (tally.v12Aware) {
212
+ return `${tally.total} ${tally.total === 1 ? 'script' : 'scripts'} · ${tally.allowed.length} allowed · ${tally.blocked.length} blocked`;
213
+ }
214
+ // No allowScripts map: npm v12 blocks every install script by default.
215
+ return `${tally.total} ${tally.total === 1 ? 'script' : 'scripts'} · ${tally.blocked.length} blocked by npm v12 (no allowScripts)`;
216
+ }
217
+
218
+ // Default pass-state summary for a generic section: count its findings or fall back.
219
+ function genericSummary(id, findings) {
220
+ const sev = worstSeverity(findings);
221
+ if (sev) return `${findings.length} ${findings.length === 1 ? 'finding' : 'findings'}`;
222
+ return DEFAULT_PASS_SUMMARY[id] || 'pass';
223
+ }
224
+
225
+ // Build a passing/severity result whose summary comes from the given producer.
226
+ function liveSection(findings, summary) {
227
+ return { status: worstSeverity(findings) || 'pass', summary };
228
+ }
229
+
230
+ // Per-section describers keyed by section id. Each returns { status, summary },
231
+ // short-circuiting to a 'skip' when the underlying check didn't run.
232
+ const SECTION_DESCRIBERS = {
233
+ integrity(findings, state) {
234
+ // The registry check may be off (--offline / --no-integrity) while the offline
235
+ // `integrity-hygiene` audit rule still bucketed findings here. A skipped section
236
+ // must not silently carry (and count) findings, so only report `skip` when the
237
+ // bucket is genuinely empty; otherwise surface the offline findings and let their
238
+ // severity drive the status and the rollup. (The `integrity: false` boolean can't
239
+ // distinguish --offline from --no-integrity, so the label stays flag-neutral.)
240
+ if (!state.integrity) {
241
+ if (findings.length === 0) return { status: 'skip', summary: 'registry check skipped' };
242
+ const n = findings.length;
243
+ return liveSection(findings, `registry check skipped · ${n} offline finding${n === 1 ? '' : 's'}`);
244
+ }
245
+ return liveSection(findings, integritySummary(state.integrityResult));
246
+ },
247
+ vuln(findings, state) {
248
+ if (!state.vuln) return { status: 'skip', summary: 'skipped (--offline)' };
249
+ return liveSection(findings, scanSummary(state.vulnResult, 'vulnerable', 'vulnerable'));
250
+ },
251
+ deprecated(findings, state) {
252
+ if (!state.deprecated) return { status: 'skip', summary: 'skipped (--offline)' };
253
+ return liveSection(findings, scanSummary(state.deprecationResult, 'deprecated', 'deprecated'));
254
+ },
255
+ licenses(findings, state) {
256
+ // An unexpected checkLicenses failure (malformed CSV, fs permission error,
257
+ // internal bug) is recorded as an error-severity finding upstream — NOT swallowed
258
+ // into a passing skip — so the license policy gate trips when the check breaks.
259
+ if (state.licenseError) return liveSection(findings, `check failed (${state.licenseError})`);
260
+ if (state.licenseSkip) return { status: 'skip', summary: `skipped (${state.licenseSkip})` };
261
+ return liveSection(findings, licenseSummary(state.licenseResult));
262
+ },
263
+ 'install-scripts'(findings, state) {
264
+ return liveSection(findings, installScriptsSummary(state.scriptTally));
265
+ }
266
+ };
267
+
268
+ // Resolve a section's { status, summary } from its findings and the run's results.
269
+ function describeSection(id, findings, state) {
270
+ if (state.flavor === 'pnpm' && !PNPM_LIVE_SECTIONS.has(id)) {
271
+ return { status: 'skip', summary: 'N/A (pnpm)' };
272
+ }
273
+ if (state.flavor !== 'pnpm' && NPM_NA_SECTIONS.has(id)) {
274
+ return { status: 'skip', summary: 'N/A (npm)' };
275
+ }
276
+ const describer = SECTION_DESCRIBERS[id];
277
+ if (describer) return describer(findings, state);
278
+ return liveSection(findings, genericSummary(id, findings));
279
+ }
280
+
281
+ /**
282
+ * Run every available check and build a structured report.
283
+ *
284
+ * @param {object} target - { lockfile, packageJson|null, filePath, dir }
285
+ * @param {object} options
286
+ * @param {object} options.auditConfig - Resolved/raw audit config (passed to runAudit)
287
+ * @param {boolean} options.integrity - Run registry integrity verification (default true)
288
+ * @param {boolean} options.license - Run license validation (default true)
289
+ * @param {string} options.licensesCsv - Path to approved-licenses CSV
290
+ * @param {string} options.nodeModulesPath - node_modules path for license reads
291
+ * @param {boolean} options.strict - Treat warnings as failures
292
+ * @param {number} options.maxWarnings - Warning budget (-1 = unlimited)
293
+ * @param {number} options.concurrency / options.timeoutMs / options.defaultRegistry / options.failOnUnresolved
294
+ * @param {Function} options.fetchIntegrity - Injectable registry transport (tests)
295
+ * @param {Function} options.onProgress - Progress callback for the integrity stage
296
+ * @returns {Promise<object>} { filePath, sections, summary }
297
+ */
298
+ // Merge caller options over the report defaults, resolving CSV / node_modules
299
+ // paths relative to the target dir. Returns the fully-defaulted option set.
300
+ function resolveRunOptions(options, dir) {
301
+ return {
302
+ auditConfig: {},
303
+ integrity: true,
304
+ license: true,
305
+ vuln: true,
306
+ deprecated: true,
307
+ failOnDeprecated: false,
308
+ minSeverity: 'high',
309
+ licensesCsv: path.join(dir, 'approved-licenses.csv'),
310
+ nodeModulesPath: path.join(dir, 'node_modules'),
311
+ strict: false,
312
+ maxWarnings: -1,
313
+ concurrency: 8,
314
+ timeoutMs: 10000,
315
+ defaultRegistry: undefined,
316
+ // Fail closed by default: a registry-backed scan that couldn't complete must not
317
+ // pass the report as clean. Set false (CLI `--allow-unresolved`) to tolerate.
318
+ failOnUnresolved: true,
319
+ fetchIntegrity: null,
320
+ fetchAdvisories: null,
321
+ fetchManifest: null,
322
+ onProgress: null,
323
+ ...options
324
+ };
325
+ }
326
+
327
+ // Install-script tally (allowed vs blocked), reconciled against npm v12's
328
+ // package.json `allowScripts` — used for the section's summary line.
329
+ function tallyInstallScripts(lockfile, packageJson, auditConfig) {
330
+ const sampleRule = auditConfig.rules && auditConfig.rules['install-scripts'];
331
+ const isResolved = sampleRule && typeof sampleRule === 'object' && !Array.isArray(sampleRule) && typeof sampleRule.severity === 'string';
332
+ const resolvedConfig = isResolved ? auditConfig : mergeConfig(auditConfig);
333
+ const scriptOptions = (resolvedConfig.rules['install-scripts'] || {}).options || {};
334
+ return classifyInstallScripts(lockfile, packageJson, scriptOptions);
335
+ }
336
+
337
+ // Bucket audit findings into report sections (normalized shape).
338
+ function bucketAuditFindings(buckets, audit) {
339
+ for (const f of audit.findings) {
340
+ const id = RULE_SECTION[f.ruleId] || 'structure';
341
+ pushFinding(buckets, id, { severity: f.severity, location: f.packagePath, message: f.message, ruleId: f.ruleId });
342
+ }
343
+ }
344
+
345
+ // Optional defaultRegistry spread, shared by every network stage.
346
+ function registryOption(defaultRegistry) {
347
+ return defaultRegistry ? { defaultRegistry } : {};
348
+ }
349
+
350
+ // Registry integrity verification (network). Returns the result or null when off.
351
+ async function runIntegrityStage(buckets, lockfile, opts) {
352
+ if (!opts.integrity) return null;
353
+ const result = await checkIntegrity(lockfile, {
354
+ concurrency: opts.concurrency, timeoutMs: opts.timeoutMs, failOnUnresolved: opts.failOnUnresolved,
355
+ fetchIntegrity: opts.fetchIntegrity, onProgress: opts.onProgress, ...registryOption(opts.defaultRegistry)
356
+ });
357
+ collectIntegrityFindings(buckets, result, opts.failOnUnresolved);
358
+ return result;
359
+ }
360
+
361
+ // Known-vulnerability scan (network; registry bulk advisory endpoint).
362
+ async function runVulnStage(buckets, lockfile, opts) {
363
+ if (!opts.vuln) return null;
364
+ const result = await checkVulnerabilities(lockfile, {
365
+ concurrency: opts.concurrency, timeoutMs: opts.timeoutMs, minSeverity: opts.minSeverity,
366
+ failOnUnresolved: opts.failOnUnresolved, fetchAdvisories: opts.fetchAdvisories, onProgress: opts.onProgress,
367
+ ...registryOption(opts.defaultRegistry)
368
+ });
369
+ collectVulnFindings(buckets, result, opts.failOnUnresolved);
370
+ return result;
371
+ }
372
+
373
+ // Deprecation scan (network; registry version manifest `deprecated` field).
374
+ async function runDeprecationStage(buckets, lockfile, opts) {
375
+ if (!opts.deprecated) return null;
376
+ const result = await checkDeprecations(lockfile, {
377
+ concurrency: opts.concurrency, timeoutMs: opts.timeoutMs, failOnDeprecated: opts.failOnDeprecated,
378
+ failOnUnresolved: opts.failOnUnresolved, fetchManifest: opts.fetchManifest, onProgress: opts.onProgress,
379
+ ...registryOption(opts.defaultRegistry)
380
+ });
381
+ collectDeprecationFindings(buckets, result, opts.failOnUnresolved);
382
+ return result;
383
+ }
384
+
385
+ // License validation (filesystem; needs node_modules + an approved list).
386
+ // Returns { licenseResult, licenseSkip, licenseError }. The two benign degrade cases
387
+ // (no node_modules, no CSV) are explicit `existsSync` skips above. An UNEXPECTED
388
+ // checkLicenses failure (malformed CSV, fs permission error, internal bug) must NOT be
389
+ // swallowed into a passing skip — it's recorded as an error-severity finding so the
390
+ // gate trips (fail-closed), and surfaced via `licenseError`.
391
+ async function runLicenseStage(buckets, lockfile, opts) {
392
+ if (!opts.license) return { licenseResult: null, licenseSkip: 'disabled' };
393
+ if (!fs.existsSync(opts.nodeModulesPath)) return { licenseResult: null, licenseSkip: 'no node_modules' };
394
+ if (!fs.existsSync(opts.licensesCsv)) return { licenseResult: null, licenseSkip: 'no approved-licenses.csv' };
395
+ try {
396
+ const licenseResult = await checkLicenses(lockfile, {
397
+ csvPath: opts.licensesCsv, nodeModulesPath: opts.nodeModulesPath, strict: opts.strict
398
+ });
399
+ collectLicenseFindings(buckets, licenseResult);
400
+ return { licenseResult, licenseSkip: null };
401
+ } catch (e) {
402
+ pushFinding(buckets, 'licenses', { severity: 'error', location: opts.licensesCsv, message: `license check failed: ${e.message}` });
403
+ return { licenseResult: null, licenseSkip: null, licenseError: e.message };
404
+ }
405
+ }
406
+
407
+ // The 5-level ladder used by the suite-wide `--fail-on severity=` gate.
408
+ const SEVERITY_RANK = { info: 0, low: 1, moderate: 2, high: 3, critical: 4 };
409
+
410
+ // Does any finding meet or exceed the severity gate? This is the suite-wide CI gate:
411
+ // each finding's ladder severity comes from `ladderSeverity()` (advisory severity, else
412
+ // error→high / warn→low), so the gate applies across EVERY section that carries
413
+ // severities — not just the vuln stage, whose `minSeverity` only shaped its own
414
+ // error/warn split. An unknown/absent gate never trips.
415
+ function severityGateTripped(findings, gate) {
416
+ const threshold = SEVERITY_RANK[gate];
417
+ if (threshold === undefined) return false;
418
+ return findings.some((f) => (SEVERITY_RANK[ladderSeverity(f)] ?? 0) >= threshold);
419
+ }
420
+
421
+ // Assemble the ordered sections (status + one-line summary) and roll up totals.
422
+ function assembleSections(buckets, sectionState, maxWarnings, severityGate) {
423
+ const sections = SECTIONS.map(({ id, title }) => {
424
+ const findings = buckets[id] || [];
425
+ const { status, summary } = describeSection(id, findings, sectionState);
426
+ return { id, title, status, summary, findings };
427
+ });
428
+
429
+ const allFindings = sections.flatMap((s) => s.findings);
430
+ const errors = allFindings.filter((f) => f.severity === 'error').length;
431
+ const warnings = allFindings.filter((f) => f.severity === 'warn').length;
432
+ // Fail-closed rollup: any error, over the warning budget, OR any finding at/above the
433
+ // severity gate (default `high`, so errors→high already trip and the default behavior
434
+ // is unchanged; lowering the gate to e.g. `low` now correctly fails on warn-tier
435
+ // findings from deprecation / install-scripts / pinned-versions / etc.).
436
+ const gateTripped = severityGateTripped(allFindings, severityGate);
437
+ const pass = errors === 0 && (maxWarnings < 0 || warnings <= maxWarnings) && !gateTripped;
438
+ return { sections, summary: { errors, warnings, total: errors + warnings, pass } };
439
+ }
440
+
441
+ export async function runReport(target, options = {}) {
442
+ const { lockfile, packageJson = null, filePath = 'package-lock.json', dir = process.cwd() } = target;
443
+ if (!lockfile || typeof lockfile !== 'object') {
444
+ throw new ReportError('lockfile data is required', 'MISSING_LOCKFILE');
445
+ }
446
+
447
+ const opts = resolveRunOptions(options, dir);
448
+ const flavor = detectLockfileFlavor(lockfile);
449
+ const isPnpm = flavor === 'pnpm';
450
+
451
+ const buckets = {};
452
+
453
+ // 1. Offline audit rules + install-script tally, bucketed into report sections.
454
+ // runAudit self-gates by flavor: on pnpm only the config rules run
455
+ // (package.json / .npmrc / pnpm-workspace.yaml + pnpm field); the npm
456
+ // lockfile-shape rules no-op and their sections render N/A. The install-script
457
+ // tally is npm-only (pnpm gates builds via onlyBuiltDependencies).
458
+ const audit = runAudit({ lockfile, packageJson, filePath }, opts.auditConfig);
459
+ bucketAuditFindings(buckets, audit);
460
+ let scriptTally = { total: 0, allowed: [], blocked: [], v12Aware: false };
461
+ if (!isPnpm) {
462
+ scriptTally = tallyInstallScripts(lockfile, packageJson, opts.auditConfig);
463
+ }
464
+
465
+ // 2–4. Network + filesystem stages (each no-ops to null when disabled). The
466
+ // registry-backed scans work for both flavors; license is npm-only for now.
467
+ const integrityResult = await runIntegrityStage(buckets, lockfile, opts);
468
+ const vulnResult = await runVulnStage(buckets, lockfile, opts);
469
+ const deprecationResult = await runDeprecationStage(buckets, lockfile, opts);
470
+ const { licenseResult, licenseSkip, licenseError = null } = isPnpm
471
+ ? { licenseResult: null, licenseSkip: 'N/A (pnpm)' }
472
+ : await runLicenseStage(buckets, lockfile, opts);
473
+
474
+ // Assemble ordered sections with status + one-line summary, then roll up totals.
475
+ const sectionState = {
476
+ flavor,
477
+ integrity: opts.integrity, integrityResult, vuln: opts.vuln, vulnResult,
478
+ deprecated: opts.deprecated, deprecationResult, licenseSkip, licenseError, licenseResult, scriptTally
479
+ };
480
+ const { sections, summary } = assembleSections(buckets, sectionState, opts.maxWarnings, opts.minSeverity);
481
+
482
+ return { filePath, scanned: countExaminedPackages(lockfile), sections, summary };
483
+ }
484
+
485
+ // Count the package entries the report examined (the non-root lockfile entries):
486
+ // v2/v3 keep them under `packages` (the "" root is excluded), v1 under `dependencies`.
487
+ function countExaminedPackages(lockfile) {
488
+ if (lockfile && lockfile.packages && typeof lockfile.packages === 'object') {
489
+ return Object.keys(lockfile.packages).filter((k) => k !== '').length;
490
+ }
491
+ if (lockfile && lockfile.dependencies && typeof lockfile.dependencies === 'object') {
492
+ return Object.keys(lockfile.dependencies).length;
493
+ }
494
+ return 0;
495
+ }
496
+
497
+ const DEFAULT_PASS_SUMMARY = {
498
+ structure: 'valid',
499
+ 'package-json': 'valid',
500
+ npmrc: 'valid',
501
+ 'pnpm-config': 'valid',
502
+ resolved: 'all TLS / trusted',
503
+ 'install-scripts': 'none',
504
+ git: 'none',
505
+ remote: 'none',
506
+ pinned: 'all pinned',
507
+ orphans: 'none',
508
+ unused: 'none',
509
+ fund: 'suppressed'
510
+ };
511
+
512
+ const ICON = { pass: ' ', warn: ' ', error: ' ', skip: '·' };
513
+
514
+ // Map each report section to a shared-schema `category`. The lockfile-hygiene
515
+ // audit sections fold into `lint`; policy-ish sections into `policy`; the scan
516
+ // sections keep their first-class categories.
517
+ const SECTION_CATEGORY = {
518
+ structure: 'lint',
519
+ 'package-json': 'lint',
520
+ npmrc: 'lint',
521
+ 'pnpm-config': 'lint',
522
+ integrity: 'integrity',
523
+ vuln: 'vulnerability',
524
+ deprecated: 'deprecated',
525
+ resolved: 'lint',
526
+ licenses: 'policy',
527
+ 'install-scripts': 'policy',
528
+ git: 'policy',
529
+ remote: 'policy',
530
+ pinned: 'policy',
531
+ orphans: 'lint',
532
+ unused: 'unused',
533
+ fund: 'lint'
534
+ };
535
+
536
+ // The report tier is error|warn; the shared ladder needs one of five strings.
537
+ // Advisory findings carry their TRUE ladder severity (advisorySeverity); for every
538
+ // other finding map error→high, warn→low (the suite's error/warn→ladder rule).
539
+ function ladderSeverity(f) {
540
+ if (f.advisorySeverity) return f.advisorySeverity;
541
+ return f.severity === 'error' ? 'high' : 'low';
542
+ }
543
+
544
+ // Map one report finding (any section) into the shared Finding shape. The report
545
+ // tier (error/warn) that drives the gate is preserved under `extra.reportSeverity`;
546
+ // advisory findings additionally carry the vuln-tool `extra` payload.
547
+ // The advisory-specific slice of a finding's `extra` payload (only populated for
548
+ // vuln/advisory findings); kept separate so reportFindingToSchema stays flat.
549
+ function advisoryExtra(f) {
550
+ return {
551
+ package: f.package ?? null,
552
+ installedVersion: f.version ?? null,
553
+ fixedVersion: f.fixedVersion ?? null,
554
+ advisoryId: f.advisoryId ?? null,
555
+ cve: f.cve ?? null,
556
+ vulnerableRange: f.vulnerableRange ?? null,
557
+ references: referencesOf(f)
558
+ };
559
+ }
560
+
561
+ function reportFindingToSchema(sectionId, f) {
562
+ const isAdvisory = f.advisoryId != null || f.advisorySeverity != null;
563
+ const extra = { section: sectionId, reportSeverity: f.severity };
564
+ if (isAdvisory) Object.assign(extra, advisoryExtra(f));
565
+ return {
566
+ severity: ladderSeverity(f),
567
+ ruleId: f.advisoryId != null ? String(f.advisoryId) : (f.ruleId || sectionId),
568
+ category: SECTION_CATEGORY[sectionId] || 'lint',
569
+ message: f.message,
570
+ location: f.location ? { file: f.location, line: null, column: null } : null,
571
+ remediation: f.fixedVersion ? `upgrade to ${f.fixedVersion}` : null,
572
+ extra
573
+ };
574
+ }
575
+
576
+ /**
577
+ * Wrap a runReport() result in the shared finding-schema envelope. The COMPLETE
578
+ * set of findings (every section, in section order) becomes the top-level
579
+ * `findings`; the section statuses/summaries and the report-tier rollup
580
+ * (errors/warnings/total/pass — the gate signal) are preserved under `extra`.
581
+ *
582
+ * @param {object} report - a runReport() result
583
+ * @param {object} [meta]
584
+ * @param {string} [meta.target] - path scanned, as given (defaults to report.filePath)
585
+ * @param {number} [meta.exitCode] - the real exit code (defaults to pass ? 0 : 1)
586
+ * @returns {object} the shared envelope
587
+ */
588
+ export function reportEnvelope(report, meta = {}) {
589
+ const target = meta.target ?? report.filePath;
590
+ const exitCode = meta.exitCode ?? (report.summary.pass ? 0 : 1);
591
+ const findings = [];
592
+ for (const section of report.sections) {
593
+ for (const f of section.findings) findings.push(reportFindingToSchema(section.id, f));
594
+ }
595
+ return buildEnvelope({
596
+ target,
597
+ scanned: report.scanned ?? 0,
598
+ findings,
599
+ exitCode,
600
+ extra: {
601
+ sections: report.sections.map((s) => ({ id: s.id, title: s.title, status: s.status, summary: s.summary })),
602
+ summary: report.summary
603
+ }
604
+ });
605
+ }
606
+
607
+ /**
608
+ * Render a report produced by runReport().
609
+ * @param {object} report
610
+ * @param {object} options - { format: 'human' | 'json', target?, exitCode? }
611
+ * In 'json' mode the output is the shared finding-schema envelope (see schema.js).
612
+ * @returns {string}
613
+ */
614
+ export function formatReport(report, options = {}) {
615
+ const { format = 'human' } = options;
616
+
617
+ if (format === 'json') {
618
+ // The shared finding-schema envelope is the ONLY thing printed in json mode.
619
+ return JSON.stringify(reportEnvelope(report, { target: options.target, exitCode: options.exitCode }), null, 2);
620
+ }
621
+ if (format !== 'human') {
622
+ throw new ReportError(`Unknown report format: ${format}`, 'UNKNOWN_FORMAT');
623
+ }
624
+
625
+ const lines = [];
626
+ lines.push(`npm-check report — ${report.filePath}`);
627
+ lines.push('');
628
+ lines.push(...renderSummaryTable(report.sections));
629
+ for (const s of report.sections) {
630
+ lines.push(...renderSectionDetail(s));
631
+ }
632
+ lines.push('');
633
+ lines.push(renderFooter(report.summary));
634
+ return lines.join('\n');
635
+ }
636
+
637
+ // Section summary table: one aligned status line per section.
638
+ function renderSummaryTable(sections) {
639
+ const titleWidth = Math.max(...sections.map((s) => s.title.length));
640
+ return sections.map((s) => ` ${ICON[s.status]} ${s.title.padEnd(titleWidth)} ${s.summary}`);
641
+ }
642
+
643
+ // Detail block for a single section — empty unless it has findings.
644
+ function renderSectionDetail(s) {
645
+ if (s.findings.length === 0) return [];
646
+ const lines = ['', `${s.title} (${s.findings.length})`];
647
+ const shown = s.findings.slice(0, MAX_DETAIL);
648
+ for (const f of shown) {
649
+ const loc = f.location ? `${f.location} ` : '';
650
+ lines.push(` ${ICON[f.severity] || ' '} ${loc}${f.message}`);
651
+ }
652
+ if (s.findings.length > shown.length) {
653
+ lines.push(` …and ${s.findings.length - shown.length} more`);
654
+ }
655
+ return lines;
656
+ }
657
+
658
+ // Closing totals line: an all-clear, or an error/warning count.
659
+ function renderFooter({ errors, warnings, total }) {
660
+ if (total === 0) return 'all checks passed';
661
+ const word = total === 1 ? 'problem' : 'problems';
662
+ return `${total} ${word} (${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'})`;
663
+ }