@dependably/npm-check 1.7.0 → 1.7.1

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 CHANGED
@@ -18,7 +18,7 @@ import { buildEnvelope } from './schema.js';
18
18
  // pin just like npm's, and the pinned-versions rule is npm+pnpm flavored). The
19
19
  // npm-lockfile-shape sections (and license, pending a `.pnpm` store walk) are
20
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']);
21
+ const PNPM_LIVE_SECTIONS = new Set(['integrity', 'vuln', 'deprecated', 'package-json', 'npmrc', 'pnpm-config', 'pinned', 'unresolved']);
22
22
  // The pnpm-config section has no meaning for an npm lockfile.
23
23
  const NPM_NA_SECTIONS = new Set(['pnpm-config']);
24
24
 
@@ -60,6 +60,13 @@ const SECTIONS = [
60
60
  { id: 'integrity', title: 'Integrity (registry)' },
61
61
  { id: 'vuln', title: 'Known vulnerabilities' },
62
62
  { id: 'deprecated', title: 'Deprecated packages' },
63
+ // moonlitlabs/npm-check#35: entries the integrity/vuln/deprecation scans could
64
+ // not check at all (registry unreachable, endpoint unsupported, …) are a
65
+ // distinct signal from what each scan actually FOUND — filing them under
66
+ // whichever scan happened to run last (previously "Deprecated packages") made
67
+ // an unrelated check look like it had findings. They're collected here instead,
68
+ // one shared section, tagged with the check that couldn't complete.
69
+ { id: 'unresolved', title: 'Unresolved (could not check)' },
63
70
  { id: 'resolved', title: 'Resolved URLs' },
64
71
  { id: 'licenses', title: 'Licenses' },
65
72
  { id: 'install-scripts', title: 'Install scripts' },
@@ -85,17 +92,52 @@ function pushFinding(buckets, id, finding) {
85
92
  list.push(finding);
86
93
  }
87
94
 
88
- // Bucket integrity findings: real hash mismatches are errors. Unresolved entries
89
- // (could-not-verify) are errors when failing closed (the default), else warnings.
95
+ // `checkIntegrity()` fails closed by default (failOnUnresolved): an unresolved
96
+ // entry (registry unreachable / no published sha512) is folded into BOTH
97
+ // `unresolvedItems` AND `errors`/`failed`, so the same package would otherwise
98
+ // be counted — and rendered — as "mismatched" as well as "unresolved". Since
99
+ // `errors` pushes the very same object reference for that case, a Set keyed on
100
+ // `unresolvedItems` reliably tells a GENUINE failure (a real hash mismatch, or
101
+ // an untrusted-host rejection) apart from an unresolved entry just riding along
102
+ // in `errors` for the fail-closed gate.
103
+ function integrityMismatches(integrityResult) {
104
+ const unresolvedSet = new Set(integrityResult.unresolvedItems);
105
+ return integrityResult.errors.filter((err) => !unresolvedSet.has(err));
106
+ }
107
+
108
+ // Bucket integrity findings: one detail line per genuinely-mismatched package.
109
+ // The detail count here always equals the summary's "mismatched" bit (see
110
+ // integritySummary()) — both are derived from integrityMismatches(). Entries
111
+ // that couldn't be checked at all go to the shared "Unresolved" section instead
112
+ // (collectUnresolvedFindings) so this section only ever reports what integrity
113
+ // verification actually FOUND.
90
114
  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
- }
115
+ for (const err of integrityMismatches(integrityResult)) {
116
+ const who = err.version ? `${err.package}@${err.version}` : err.package;
117
+ const detail = err.reason || 'lockfile hash differs from registry';
118
+ pushFinding(buckets, 'integrity', { severity: 'error', location: err.packagePath, message: `mismatched: ${who}: ${detail}` });
95
119
  }
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}` });
120
+ collectUnresolvedFindings(buckets, 'integrity', 'integrity', integrityResult.unresolvedItems, failOnUnresolved);
121
+ }
122
+
123
+ // moonlitlabs/npm-check#35: shared collector for the "Unresolved (could not
124
+ // check)" section — every registry-backed scan (integrity/vuln/deprecated) uses
125
+ // the same `{ package, version, packagePath, reason }` unresolved-item shape, so
126
+ // one function renders them identically instead of each scan inventing its own
127
+ // "unresolved:" / "could not scan" phrasing. `check` tags which scan couldn't
128
+ // complete (surfaced in the message and carried structurally for JSON/grouping);
129
+ // `category` is the schema category that finding would have carried had it
130
+ // stayed in its own section (see reportFindingToSchema).
131
+ function collectUnresolvedFindings(buckets, check, category, unresolvedItems, failOnUnresolved) {
132
+ const severity = failOnUnresolved ? 'error' : 'warn';
133
+ for (const item of unresolvedItems) {
134
+ pushFinding(buckets, 'unresolved', {
135
+ severity,
136
+ location: item.packagePath,
137
+ message: `[${check}] ${item.package}@${item.version}: ${item.reason}`,
138
+ check,
139
+ category
140
+ });
99
141
  }
100
142
  }
101
143
 
@@ -130,31 +172,30 @@ function advisoryFinding(level, f) {
130
172
  };
131
173
  }
132
174
 
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).
175
+ // Bucket vulnerability findings. Advisory findings (the section's own unit see
176
+ // SECTION_HEADER_LABEL.vuln) are errors/warnings. Entries the scan could not
177
+ // check at all go to the shared "Unresolved" section (not from `errors`, where
178
+ // they have no advisoryId).
136
179
  function collectVulnFindings(buckets, vulnResult, failOnUnresolved) {
137
180
  for (const err of vulnResult.errors) {
138
181
  // 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.
182
+ // unresolved entry carries a `reason` and is rendered via the shared
183
+ // unresolved collector below, while a genuine advisory has none — including
184
+ // one that merely lacks an `id`, which must still fail the run rather than
185
+ // silently vanish.
142
186
  if (err.reason) continue;
143
187
  pushFinding(buckets, 'vuln', advisoryFinding('error', err));
144
188
  }
145
189
  for (const warn of vulnResult.warnings) {
146
190
  pushFinding(buckets, 'vuln', advisoryFinding('warn', warn));
147
191
  }
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
- }
192
+ collectUnresolvedFindings(buckets, 'vuln', 'vulnerability', vulnResult.unresolvedItems, failOnUnresolved);
152
193
  }
153
194
 
154
195
  // 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`).
196
+ // failOnDeprecated (it lands in `errors` with a message), else a warning. Entries
197
+ // the scan could not check at all go to the shared "Unresolved" section (those in
198
+ // `errors` for the fail-closed gate carry no `message`).
158
199
  function collectDeprecationFindings(buckets, deprecationResult, failOnUnresolved) {
159
200
  for (const err of deprecationResult.errors) {
160
201
  if (!err.message) continue;
@@ -163,10 +204,7 @@ function collectDeprecationFindings(buckets, deprecationResult, failOnUnresolved
163
204
  for (const warn of deprecationResult.warnings) {
164
205
  pushFinding(buckets, 'deprecated', { severity: 'warn', location: warn.packagePath, message: `${warn.package}@${warn.version}: ${warn.message}` });
165
206
  }
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
- }
207
+ collectUnresolvedFindings(buckets, 'deprecated', 'deprecated', deprecationResult.unresolvedItems, failOnUnresolved);
170
208
  }
171
209
 
172
210
  // Bucket license findings: rejected licenses are errors, unknown licenses warn.
@@ -179,24 +217,49 @@ function collectLicenseFindings(buckets, licenseResult) {
179
217
  }
180
218
  }
181
219
 
182
- // One-line summary for the integrity section's count bits.
220
+ // One-line summary for the integrity section's count bits. "mismatched" counts
221
+ // only GENUINE failures (integrityMismatches) — not `r.failed`, which also folds
222
+ // in unresolved entries when failing closed and would otherwise double-count the
223
+ // same package as both "mismatched" and "unresolved". Unresolved entries are no
224
+ // longer summarized here — they're counted (and detailed) in the shared
225
+ // "Unresolved (could not check)" section instead (moonlitlabs/npm-check#35).
183
226
  function integritySummary(r) {
227
+ const mismatched = integrityMismatches(r).length;
184
228
  const bits = [`${r.passed} verified`];
185
- if (r.failed) bits.push(`${r.failed} mismatched`);
186
- if (r.unresolved) bits.push(`${r.unresolved} unresolved`);
229
+ if (mismatched) bits.push(`${mismatched} mismatched`);
187
230
  if (r.skipped) bits.push(`${r.skipped} skipped`);
188
231
  return bits.join(' · ');
189
232
  }
190
233
 
191
234
  // One-line summary shared by the vuln and deprecation sections (scanned/flagged/…).
192
- function scanSummary(r, flaggedKey, flaggedLabel) {
235
+ // `flaggedAdjective` + "package(s)" always names the UNIT being counted
236
+ // ("vulnerable packages", "deprecated packages"), pluralized to match the count,
237
+ // so it reads the same as the section header's count (moonlitlabs/npm-check#35)
238
+ // — never a bare adjective a reader has to guess the unit of. `detail`, when
239
+ // given, appends a labeled sub-count in a trailing parenthetical for units that
240
+ // don't map 1:1 to packages (a vulnerable package can carry more than one
241
+ // advisory). Unresolved entries are summarized in the shared "Unresolved (could
242
+ // not check)" section instead.
243
+ function scanSummary(r, flaggedKey, flaggedAdjective, detail = null) {
193
244
  const bits = [`${r.scanned} scanned`];
194
- if (r[flaggedKey]) bits.push(`${r[flaggedKey]} ${flaggedLabel}`);
195
- if (r.unresolved) bits.push(`${r.unresolved} unresolved`);
245
+ const n = r[flaggedKey];
246
+ if (n) {
247
+ const unit = `${flaggedAdjective} package${n === 1 ? '' : 's'}`;
248
+ bits.push(`${n} ${unit}${detail ? ` (${detail})` : ''}`);
249
+ }
196
250
  if (r.skipped) bits.push(`${r.skipped} skipped`);
197
251
  return bits.join(' · ');
198
252
  }
199
253
 
254
+ // One-line summary for the shared "Unresolved (could not check)" section: a
255
+ // breakdown by originating check (integrity/vuln/deprecated) so a reader knows
256
+ // which scan(s) couldn't complete without opening the detail block below.
257
+ function unresolvedSummary(findings) {
258
+ const byCheck = new Map();
259
+ for (const f of findings) byCheck.set(f.check, (byCheck.get(f.check) || 0) + 1);
260
+ return [...byCheck.entries()].map(([check, n]) => `${n} ${check}`).join(' · ');
261
+ }
262
+
200
263
  // One-line summary for the license section's count bits.
201
264
  function licenseSummary(r) {
202
265
  const bits = [`${r.approved} ok`];
@@ -237,27 +300,45 @@ const SECTION_DESCRIBERS = {
237
300
  // bucket is genuinely empty; otherwise surface the offline findings and let their
238
301
  // severity drive the status and the rollup. (The `integrity: false` boolean can't
239
302
  // distinguish --offline from --no-integrity, so the label stays flag-neutral.)
303
+ // moonlitlabs/npm-check#35: the bare detail text no longer says "skipped" —
304
+ // the fixed status column already says that (statusLabel()); the detail is
305
+ // just the reason, so the rendered row reads "skipped (--offline / …)"
306
+ // instead of the old "skipped (registry check skipped)".
240
307
  if (!state.integrity) {
241
- if (findings.length === 0) return { status: 'skip', summary: 'registry check skipped' };
308
+ if (findings.length === 0) return { status: 'skip', summary: '--offline / --no-integrity' };
242
309
  const n = findings.length;
243
310
  return liveSection(findings, `registry check skipped · ${n} offline finding${n === 1 ? '' : 's'}`);
244
311
  }
245
312
  return liveSection(findings, integritySummary(state.integrityResult));
246
313
  },
247
314
  vuln(findings, state) {
248
- if (!state.vuln) return { status: 'skip', summary: 'skipped (--offline)' };
249
- return liveSection(findings, scanSummary(state.vulnResult, 'vulnerable', 'vulnerable'));
315
+ if (!state.vuln) return { status: 'skip', summary: '--offline' };
316
+ // moonlitlabs/npm-check#35: `findings` here is ONLY advisory findings (the
317
+ // unresolved entries that used to ride along have moved to the shared
318
+ // "Unresolved" section — see collectVulnFindings), so its length IS the
319
+ // advisory count, matching the section header 1:1 (SECTION_HEADER_LABEL.vuln).
320
+ const n = findings.length;
321
+ const detail = n ? `${n} advisor${n === 1 ? 'y' : 'ies'}` : null;
322
+ return liveSection(findings, scanSummary(state.vulnResult, 'vulnerable', 'vulnerable', detail));
250
323
  },
251
324
  deprecated(findings, state) {
252
- if (!state.deprecated) return { status: 'skip', summary: 'skipped (--offline)' };
325
+ if (!state.deprecated) return { status: 'skip', summary: '--offline' };
253
326
  return liveSection(findings, scanSummary(state.deprecationResult, 'deprecated', 'deprecated'));
254
327
  },
328
+ // moonlitlabs/npm-check#35: the shared "Unresolved (could not check)" section.
329
+ // Each finding carries `check` (integrity/vuln/deprecated); the summary breaks
330
+ // the total down by check so a reader can tell WHICH scan(s) couldn't complete
331
+ // without opening the detail block.
332
+ unresolved(findings) {
333
+ if (findings.length === 0) return { status: 'pass', summary: 'none' };
334
+ return liveSection(findings, unresolvedSummary(findings));
335
+ },
255
336
  licenses(findings, state) {
256
337
  // An unexpected checkLicenses failure (malformed CSV, fs permission error,
257
338
  // internal bug) is recorded as an error-severity finding upstream — NOT swallowed
258
339
  // into a passing skip — so the license policy gate trips when the check breaks.
259
340
  if (state.licenseError) return liveSection(findings, `check failed (${state.licenseError})`);
260
- if (state.licenseSkip) return { status: 'skip', summary: `skipped (${state.licenseSkip})` };
341
+ if (state.licenseSkip) return { status: 'skip', summary: state.licenseSkip };
261
342
  return liveSection(findings, licenseSummary(state.licenseResult));
262
343
  },
263
344
  'install-scripts'(findings, state) {
@@ -320,10 +401,70 @@ function resolveRunOptions(options, dir) {
320
401
  fetchAdvisories: null,
321
402
  fetchManifest: null,
322
403
  onProgress: null,
404
+ // The `secure-resolved` and `no-remote-deps` audit rules independently flag
405
+ // a package resolved from an untrusted/unrecognized host — for a private
406
+ // registry mirror EVERY package resolved from it trips both rules, so
407
+ // "Resolved URLs" and "Remote-URL deps" end up reporting the same packages
408
+ // twice (one root cause, two lines). By default the report cross-references
409
+ // and collapses those duplicates (dedupeRemoteFindings); `verbose: true`
410
+ // (CLI `--verbose`) opts back into the full per-package listing.
411
+ verbose: false,
323
412
  ...options
324
413
  };
325
414
  }
326
415
 
416
+ // Pull a hostname out of a "no-remote-deps" finding's message, which always
417
+ // embeds the offending URL in parentheses (`${name} resolves from a remote
418
+ // URL (${resolved}) — …`). Returns null when the URL can't be parsed.
419
+ function extractRemoteHost(message) {
420
+ const match = message.match(/\(([^()]*:\/\/[^()]+)\)/);
421
+ if (!match) return null;
422
+ try {
423
+ return new URL(match[1]).hostname;
424
+ } catch {
425
+ return null;
426
+ }
427
+ }
428
+
429
+ // Cross-reference "Remote-URL deps" (remote) against "Resolved URLs" (resolved):
430
+ // a `remote` finding whose package is ALREADY flagged in `resolved` is the same
431
+ // root cause (an untrusted/unrecognized host) reported twice, not two distinct
432
+ // problems. Collapse those into one grouped-by-host finding per host instead of
433
+ // one line per duplicated package — counting root causes, not duplicated lines.
434
+ // Findings for packages `remote` flags that `resolved` did NOT (e.g. a host
435
+ // trusted by one rule's config but not the other's) are left untouched.
436
+ function dedupeRemoteFindings(buckets) {
437
+ const resolved = buckets.resolved;
438
+ const remote = buckets.remote;
439
+ if (!resolved || !remote || resolved.length === 0 || remote.length === 0) return;
440
+
441
+ const resolvedPaths = new Set(resolved.map((f) => f.location));
442
+ const distinct = [];
443
+ const duplicatesByHost = new Map(); // host -> { count, severity }
444
+
445
+ for (const f of remote) {
446
+ if (!resolvedPaths.has(f.location)) {
447
+ distinct.push(f);
448
+ continue;
449
+ }
450
+ const host = extractRemoteHost(f.message) || 'an unrecognized host';
451
+ const group = duplicatesByHost.get(host) || { count: 0, severity: f.severity };
452
+ group.count++;
453
+ if (f.severity === 'error') group.severity = 'error'; // keep the worst severity seen
454
+ duplicatesByHost.set(host, group);
455
+ }
456
+
457
+ for (const [host, { count, severity }] of duplicatesByHost) {
458
+ distinct.push({
459
+ severity,
460
+ location: null,
461
+ message: `${count} package${count === 1 ? '' : 's'} resolved from "${host}" — already reported under Resolved URLs (npm v12 needs --allow-remote; pass --verbose to list each package)`
462
+ });
463
+ }
464
+
465
+ buckets.remote = distinct;
466
+ }
467
+
327
468
  // Install-script tally (allowed vs blocked), reconciled against npm v12's
328
469
  // package.json `allowScripts` — used for the section's summary line.
329
470
  function tallyInstallScripts(lockfile, packageJson, auditConfig) {
@@ -457,6 +598,10 @@ export async function runReport(target, options = {}) {
457
598
  // tally is npm-only (pnpm gates builds via onlyBuiltDependencies).
458
599
  const audit = runAudit({ lockfile, packageJson, filePath }, opts.auditConfig);
459
600
  bucketAuditFindings(buckets, audit);
601
+ // Cross-reference "Remote-URL deps" against "Resolved URLs" so one untrusted
602
+ // host doesn't get reported once per package in each section (opt out with
603
+ // `verbose: true` / CLI `--verbose` for the full per-package listing).
604
+ if (!opts.verbose) dedupeRemoteFindings(buckets);
460
605
  let scriptTally = { total: 0, allowed: [], blocked: [], v12Aware: false };
461
606
  if (!isPnpm) {
462
607
  scriptTally = tallyInstallScripts(lockfile, packageJson, opts.auditConfig);
@@ -509,11 +654,18 @@ const DEFAULT_PASS_SUMMARY = {
509
654
  fund: 'suppressed'
510
655
  };
511
656
 
512
- const ICON = { pass: ' ', warn: ' ', error: ' ', skip: '·' };
657
+ // moonlitlabs/npm-check#35: one glyph per fixed status (see statusLabel()
658
+ // below) — consistently applied so a reader can scan the icon column alone
659
+ // and know the state, instead of the icon-and-vocabulary pair drifting per
660
+ // section.
661
+ const STATUS_ICON = { pass: '✓', warn: '⚠', error: '✖', skip: '·' };
513
662
 
514
663
  // Map each report section to a shared-schema `category`. The lockfile-hygiene
515
664
  // audit sections fold into `lint`; policy-ish sections into `policy`; the scan
516
- // sections keep their first-class categories.
665
+ // sections keep their first-class categories. "unresolved" has no category of
666
+ // its own — every finding in that section carries an explicit `category` (the
667
+ // category it would have had in its own section; see collectUnresolvedFindings)
668
+ // which reportFindingToSchema prefers over this table.
517
669
  const SECTION_CATEGORY = {
518
670
  structure: 'lint',
519
671
  'package-json': 'lint',
@@ -562,10 +714,13 @@ function reportFindingToSchema(sectionId, f) {
562
714
  const isAdvisory = f.advisoryId != null || f.advisorySeverity != null;
563
715
  const extra = { section: sectionId, reportSeverity: f.severity };
564
716
  if (isAdvisory) Object.assign(extra, advisoryExtra(f));
717
+ if (f.check) extra.check = f.check; // shared "Unresolved" section: which scan couldn't complete
565
718
  return {
566
719
  severity: ladderSeverity(f),
567
720
  ruleId: f.advisoryId != null ? String(f.advisoryId) : (f.ruleId || sectionId),
568
- category: SECTION_CATEGORY[sectionId] || 'lint',
721
+ // A finding carries its own `category` when its section (unresolved) has no
722
+ // single category of its own; otherwise fall back to the section's category.
723
+ category: f.category || SECTION_CATEGORY[sectionId] || 'lint',
569
724
  message: f.message,
570
725
  location: f.location ? { file: f.location, line: null, column: null } : null,
571
726
  remediation: f.fixedVersion ? `upgrade to ${f.fixedVersion}` : null,
@@ -634,20 +789,72 @@ export function formatReport(report, options = {}) {
634
789
  return lines.join('\n');
635
790
  }
636
791
 
637
- // Section summary table: one aligned status line per section.
792
+ // moonlitlabs/npm-check#35: the fixed status-column vocabulary. Every section
793
+ // renders one of these four labels — never a check-invented phrase like
794
+ // "valid" or "all TLS / trusted" (those are still shown, but demoted to the
795
+ // trailing detail parenthetical) — so the state is readable at a glance and
796
+ // phrased identically for a given state across every section and every run.
797
+ // The count in the warn/error labels is the number of findings AT that
798
+ // severity in the section (not the total, which may mix both tiers).
799
+ function statusLabel(s) {
800
+ if (s.status === 'pass') return 'ok';
801
+ if (s.status === 'skip') return 'skipped';
802
+ const n = s.findings.filter((f) => f.severity === s.status).length;
803
+ const word = s.status === 'error' ? 'error' : 'warning';
804
+ return `${n} ${word}${n === 1 ? '' : 's'}`;
805
+ }
806
+
807
+ // Section summary table: one aligned status line per section — glyph, title,
808
+ // the fixed status label, then check-specific detail in a trailing
809
+ // parenthetical (moonlitlabs/npm-check#35).
638
810
  function renderSummaryTable(sections) {
639
811
  const titleWidth = Math.max(...sections.map((s) => s.title.length));
640
- return sections.map((s) => ` ${ICON[s.status]} ${s.title.padEnd(titleWidth)} ${s.summary}`);
812
+ const labels = sections.map(statusLabel);
813
+ const labelWidth = Math.max(...labels.map((l) => l.length));
814
+ return sections.map((s, i) => {
815
+ const label = labels[i].padEnd(labelWidth);
816
+ const detail = s.summary ? ` (${s.summary})` : '';
817
+ return ` ${STATUS_ICON[s.status]} ${s.title.padEnd(titleWidth)} ${label}${detail}`;
818
+ });
819
+ }
820
+
821
+ // Literal severity tag for a detail line — mirrors the standalone `audit`
822
+ // command's own stylish vocabulary (formatAuditReport's `error`/`warn `), so a
823
+ // reader can tell at a glance which lines actually drive the exit code instead
824
+ // of a "problems" count that doesn't visibly correlate with any one line.
825
+ function severityTag(severity) {
826
+ return severity === 'error' ? 'error' : 'warn ';
827
+ }
828
+
829
+ // moonlitlabs/npm-check#35: per-section detail-header overrides — the count
830
+ // next to a section's title in its own unit, matching the summary table row
831
+ // instead of a bare, ambiguous number. "Known vulnerabilities" is the unit
832
+ // mismatch the ticket called out: findings here are one per ADVISORY (a
833
+ // package can carry several), so the header spells out both the advisory
834
+ // count (== findings.length) and the distinct-package count.
835
+ const SECTION_HEADER_LABEL = {
836
+ vuln(findings) {
837
+ const advisories = findings.length;
838
+ const packages = new Set(findings.map((f) => `${f.package}@${f.version}`)).size;
839
+ return `${advisories} advisor${advisories === 1 ? 'y' : 'ies'} in ${packages} package${packages === 1 ? '' : 's'}`;
840
+ }
841
+ };
842
+
843
+ function sectionHeaderLabel(s) {
844
+ const custom = SECTION_HEADER_LABEL[s.id];
845
+ if (custom) return custom(s.findings);
846
+ const n = s.findings.length;
847
+ return `${n} finding${n === 1 ? '' : 's'}`;
641
848
  }
642
849
 
643
850
  // Detail block for a single section — empty unless it has findings.
644
851
  function renderSectionDetail(s) {
645
852
  if (s.findings.length === 0) return [];
646
- const lines = ['', `${s.title} (${s.findings.length})`];
853
+ const lines = ['', `${s.title} ${sectionHeaderLabel(s)}`];
647
854
  const shown = s.findings.slice(0, MAX_DETAIL);
648
855
  for (const f of shown) {
649
856
  const loc = f.location ? `${f.location} ` : '';
650
- lines.push(` ${ICON[f.severity] || ' '} ${loc}${f.message}`);
857
+ lines.push(` ${severityTag(f.severity)} ${loc}${f.message}`);
651
858
  }
652
859
  if (s.findings.length > shown.length) {
653
860
  lines.push(` …and ${s.findings.length - shown.length} more`);
@@ -655,9 +862,15 @@ function renderSectionDetail(s) {
655
862
  return lines;
656
863
  }
657
864
 
658
- // Closing totals line: an all-clear, or an error/warning count.
659
- function renderFooter({ errors, warnings, total }) {
865
+ // Closing totals line: an all-clear, or an error/warning count plus a next-step
866
+ // hint when warnings alone did not fail the run (so "N warnings" doesn't read
867
+ // as ambiguous next to an exit code of 0).
868
+ function renderFooter({ errors, warnings, total, pass }) {
660
869
  if (total === 0) return 'all checks passed';
661
870
  const word = total === 1 ? 'problem' : 'problems';
662
- return `${total} ${word} (${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'})`;
871
+ const line = `${total} ${word} (${errors} error${errors === 1 ? '' : 's'}, ${warnings} warning${warnings === 1 ? '' : 's'})`;
872
+ if (pass && warnings > 0) {
873
+ return `${line}\nwarnings above don't affect exit status; use \`npm-check report --fail-on count=0\` (or a severity gate) to fail CI on them`;
874
+ }
875
+ return line;
663
876
  }