@dependably/npm-check 1.7.0 → 1.8.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/README.md CHANGED
@@ -60,11 +60,7 @@ for every command, flag, and exit code.
60
60
 
61
61
  - **[CLI reference](https://github.com/dependably/npm-check/blob/main/docs/CLI.md)** — all commands, flags, exit codes, JSON output
62
62
  - **[API guide](https://github.com/dependably/npm-check/blob/main/docs/API.md)** — using it as a library
63
- - [Performance](https://github.com/dependably/npm-check/blob/main/docs/PERFORMANCE.md) ·
64
- [Testing](https://github.com/dependably/npm-check/blob/main/docs/TESTING.md)
65
- - [Changelog](https://github.com/dependably/npm-check/blob/main/CHANGELOG.md) ·
66
- [Contributing](https://github.com/dependably/npm-check/blob/main/CONTRIBUTING.md) ·
67
- [Security](https://github.com/dependably/npm-check/blob/main/SECURITY.md)
63
+ - [Changelog](https://github.com/dependably/npm-check/blob/main/CHANGELOG.md)
68
64
 
69
65
  ## License
70
66
 
package/bin/cli.js CHANGED
@@ -11,7 +11,7 @@ import { migrateToVersion } from '../src/migrator.js';
11
11
  import { upgradeIntegrityHashes, deduplicatePackages } from '../src/updater.js';
12
12
  import { fixPackageLock } from '../src/fixer.js';
13
13
  import { createBackup, listBackups, restoreFromLatestBackup, cleanOldBackups, BackupError } from '../src/backup.js';
14
- import { createProgressBar } from '../src/progress-reporter.js';
14
+ import { formatCliProgressUpdate } from '../src/progress-reporter.js';
15
15
  import { checkIntegrity, checkLicenses } from '../src/checker.js';
16
16
  import { checkVulnerabilities, vulnEnvelope } from '../src/vuln.js';
17
17
  import { checkDeprecations, deprecationEnvelope } from '../src/deprecation.js';
@@ -82,11 +82,14 @@ Report Options:
82
82
  --no-vuln Skip the known-vulnerability scan
83
83
  --no-deprecated Skip the deprecation scan
84
84
  --no-license Skip the license check
85
- --config <file> Suite config (.dependably-check), discovered by walking
86
- up to the repo root; .npm-checkrc.json is a fallback
85
+ --config <file> Suite config (.dependably; .dependably-check is a
86
+ deprecated alias), discovered by walking up to the
87
+ repo root; .npm-checkrc.json is a fallback
87
88
  --format human|json Output format (default: human; json emits the shared finding schema)
88
89
  --allow-unresolved Don't fail when a registry-backed scan can't complete
89
90
  (registry down / endpoint unsupported). Default: FAIL CLOSED
91
+ --verbose List every "Remote-URL deps" package individually instead of
92
+ the default grouped-by-host cross-reference against "Resolved URLs"
90
93
  --concurrency / --timeout / --registry / --licenses-csv (as in Check Options)
91
94
 
92
95
  Check Options:
@@ -145,11 +148,13 @@ Unused Options:
145
148
  --format human|json Output format (default: human; json is machine-readable)
146
149
 
147
150
  Audit Options:
148
- --config <file> Suite config (.dependably-check), discovered by walking
149
- up to the repo root; .npm-checkrc.json is a fallback
151
+ --config <file> Suite config (.dependably; .dependably-check is a
152
+ deprecated alias), discovered by walking up to the
153
+ repo root; .npm-checkrc.json is a fallback
150
154
  --rule <id>:<severity> Override a rule severity (error|warn|off); repeatable
151
155
  --fail-on count=<N> Fail when the warning count exceeds N (count=0 fails on
152
156
  any warning; --max-warnings / --strict are deprecated aliases)
157
+ --show-suppressed List findings suppressed by .dependably exceptions
153
158
  --format stylish|json Report format (default: stylish)
154
159
 
155
160
  General Options:
@@ -266,15 +271,16 @@ function handleError(error, context = '') {
266
271
  process.exit(2);
267
272
  }
268
273
 
269
- // Build a progress callback that redraws the bar only when the percentage
270
- // changes, to avoid flicker. Each caller keeps its own `lastProgress` state.
274
+ // Build a progress callback that writes to stderr (stdout stays report-only).
275
+ // On a real TTY it redraws an animated `\r` bar in place; when stdout is NOT a
276
+ // TTY (piped, redirected, `tee`, CI logs) it degrades to periodic one-line
277
+ // milestones instead — see formatCliProgressUpdate() for why.
271
278
  function makeProgressReporter() {
272
- let lastProgress = null;
279
+ const isTTY = Boolean(process.stdout.isTTY);
280
+ const state = { lastPercentage: -1, lastMilestone: -1 };
273
281
  return (progress) => {
274
- if (!lastProgress || progress.percentage !== lastProgress.percentage) {
275
- process.stderr.write(`\r${createProgressBar(progress)} ${progress.stage}`);
276
- lastProgress = progress;
277
- }
282
+ const line = formatCliProgressUpdate(progress, state, isTTY);
283
+ if (line) process.stderr.write(line);
278
284
  };
279
285
  }
280
286
 
@@ -325,7 +331,8 @@ const VALUED_OPTIONS = new Set([
325
331
  const BOOLEAN_OPTIONS = new Set([
326
332
  '--offline', '--allow-unresolved',
327
333
  '--no-integrity', '--no-vuln', '--no-deprecated', '--no-license',
328
- '--include-dev', '--include-peer', '--write', '--local-fallback',
334
+ '--include-dev', '--include-peer', '--write', '--local-fallback', '--verbose',
335
+ '--show-suppressed',
329
336
  '--version', '--help', '-h',
330
337
  // deprecated boolean aliases (still parsed)
331
338
  '--strict', '--fail-on-deprecated'
@@ -496,8 +503,11 @@ function registryOption(defaultRegistry) {
496
503
  return defaultRegistry ? { defaultRegistry } : {};
497
504
  }
498
505
 
499
- // Clear the in-progress progress bar line.
506
+ // Clear the in-progress progress bar line. Only meaningful on a real TTY —
507
+ // the non-TTY reporter never draws an in-place line to begin with, so this
508
+ // would otherwise just emit a stray blank-ish line into piped/CI output.
500
509
  function clearProgressLine() {
510
+ if (!process.stdout.isTTY) return;
501
511
  process.stderr.write('\r' + ' '.repeat(80) + '\r');
502
512
  }
503
513
 
@@ -610,6 +620,9 @@ function parseReportOptions() {
610
620
  strict,
611
621
  maxWarnings,
612
622
  format: parseFormatFlag(['human', 'json'], 'human'),
623
+ // List every duplicated "Remote-URL deps" package individually instead of
624
+ // the default grouped-by-host cross-reference against "Resolved URLs".
625
+ verbose: argv.includes('--verbose'),
613
626
  // Network/integrity + license toggles.
614
627
  integrity: !argv.includes('--offline') && !argv.includes('--no-integrity'),
615
628
  license: !argv.includes('--no-license'),
@@ -650,7 +663,7 @@ async function runReportCommand() {
650
663
  auditConfig: opts.config, integrity: opts.integrity, license: opts.license, vuln: opts.vuln,
651
664
  deprecated: opts.deprecated, failOnDeprecated: opts.failOnDeprecated, minSeverity: opts.minSeverity,
652
665
  strict: opts.strict, maxWarnings: opts.maxWarnings, concurrency: opts.concurrency,
653
- timeoutMs: opts.timeoutMs, failOnUnresolved: opts.failOnUnresolved, onProgress,
666
+ timeoutMs: opts.timeoutMs, failOnUnresolved: opts.failOnUnresolved, verbose: opts.verbose, onProgress,
654
667
  ...registryOption(opts.defaultRegistry),
655
668
  ...(opts.licensesCsv ? { licensesCsv: opts.licensesCsv } : {})
656
669
  }
@@ -980,6 +993,24 @@ function runUnusedCommand() {
980
993
  }
981
994
  }
982
995
 
996
+ // Print .dependably config notices (deprecated filename/section, unknown keys)
997
+ // to stderr. Never affects exit codes or the JSON payload on stdout.
998
+ function emitConfigWarnings(warnings, format) {
999
+ if (format === 'json' || !Array.isArray(warnings)) return;
1000
+ for (const w of warnings) console.error(`.dependably: ${w.message}`);
1001
+ }
1002
+
1003
+ // Print unused / expired exception notices to stderr (spec §6.4/§6.5).
1004
+ function emitExceptionWarnings(meta, format) {
1005
+ if (format === 'json' || !meta) return;
1006
+ for (const ex of meta.expired || []) {
1007
+ console.error(`.dependably: exception expired ${ex.expires} for rule "${ex.rule}" — ${ex.reason}`);
1008
+ }
1009
+ for (const ex of meta.unused || []) {
1010
+ console.error(`.dependably: unused exception for rule "${ex.rule}" — ${ex.reason}`);
1011
+ }
1012
+ }
1013
+
983
1014
  function runAuditCommand() {
984
1015
  const filePath = getFilePath(positionals()[0]);
985
1016
  // The audit command gates on warning/finding count, not severity level.
@@ -998,13 +1029,18 @@ function runAuditCommand() {
998
1029
  applyRuleOverrides(config);
999
1030
  applyMaxWarnings(config);
1000
1031
  const format = parseFormatFlag(['stylish', 'json'], 'stylish');
1032
+ const showSuppressed = argv.includes('--show-suppressed');
1033
+
1034
+ // Surface .dependably deprecation / unknown-key notices (never gating).
1035
+ emitConfigWarnings(config.warnings, format);
1001
1036
 
1002
1037
  const lockfile = parseLockfile(filePath);
1003
1038
  // package.json is optional; the pinned-versions rule degrades gracefully
1004
1039
  const packageJson = loadSiblingPackageJson(filePath);
1005
1040
 
1006
1041
  report = runAudit({ lockfile, packageJson, filePath: path.relative(process.cwd(), filePath) || filePath }, config);
1007
- console.log('\n' + formatAuditReport(report, { format }));
1042
+ emitExceptionWarnings(report.exceptionsMeta, format);
1043
+ console.log('\n' + formatAuditReport(report, { format, showSuppressed }));
1008
1044
  } catch (error) {
1009
1045
  console.error(`\nAudit error: ${error.message}`);
1010
1046
  process.exit(2);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@dependably/npm-check",
3
- "version": "1.7.0",
3
+ "version": "1.8.0",
4
4
  "description": "A comprehensive tool for validating, migrating, and updating npm package-lock.json files across versions 1, 2, and 3.",
5
5
  "main": "src/index.js",
6
6
  "type": "module",
@@ -1,6 +1,7 @@
1
1
  // src/audit-config.js
2
2
  import fs from 'fs';
3
3
  import path from 'path';
4
+ import { parseExceptions } from './exceptions.js';
4
5
 
5
6
  export class AuditConfigError extends Error {
6
7
  constructor(message, code, context = {}) {
@@ -14,9 +15,30 @@ export class AuditConfigError extends Error {
14
15
  export const CONFIG_FILENAMES = ['.npm-checkrc.json', 'npm-check.config.json'];
15
16
 
16
17
  // Shared, cross-tool config file (JSON, no extension) discovered by walking up
17
- // from the working directory. Its `common`/`npm` `allowedRegistryHosts` extend
18
- // the secure-resolved trusted-host allowlist additively (public npm stays trusted).
19
- export const SHARED_CONFIG_FILENAME = '.dependably-check';
18
+ // from the working directory. `.dependably` is canonical; `.dependably-check` is
19
+ // a deprecated alias kept for the migration window (docs/dependably-config-spec.md §7).
20
+ export const SHARED_CONFIG_FILENAME = '.dependably';
21
+ export const DEPRECATED_SHARED_CONFIG_FILENAME = '.dependably-check';
22
+ // Checked in this order at each directory level (canonical wins).
23
+ export const SHARED_CONFIG_FILENAMES = [SHARED_CONFIG_FILENAME, DEPRECATED_SHARED_CONFIG_FILENAME];
24
+
25
+ // Canonical section key for npm-check, plus the deprecated ecosystem alias.
26
+ export const SECTION_KEY = 'npm-check';
27
+ export const DEPRECATED_SECTION_KEY = 'npm';
28
+
29
+ // Highest .dependably format version this build understands.
30
+ export const SUPPORTED_CONFIG_VERSION = 1;
31
+
32
+ // Exception selectors an npm-check finding can carry (spec §6.7). `path`/`symbol`
33
+ // are code-location selectors used by the C# tools; they are errors in npm-check's
34
+ // own section but tolerated (ignored) in `common`.
35
+ export const APPLICABLE_SELECTORS = ['package', 'id'];
36
+
37
+ // Keys npm-check recognizes inside `common` / its own section. Unknown keys warn.
38
+ const KNOWN_SECTION_KEYS = new Set([
39
+ 'rules', 'exceptions', 'exclude', 'failOn',
40
+ 'allowedRegistryHosts', 'allowedLocalFeeds', 'maxWarnings'
41
+ ]);
20
42
 
21
43
  export const SEVERITIES = ['error', 'warn', 'off'];
22
44
 
@@ -36,7 +58,7 @@ export const DEFAULT_CONFIG = {
36
58
  'install-scripts': ['warn', { allow: [] }],
37
59
  'no-git-deps': 'warn',
38
60
  'no-remote-deps': ['warn', { allowedHosts: ['registry.npmjs.org', 'npm.pkg.github.com'] }],
39
- 'pinned-versions': ['warn', {
61
+ 'pinned-versions': ['error', {
40
62
  sections: ['dependencies', 'devDependencies', 'optionalDependencies'],
41
63
  ignore: []
42
64
  }],
@@ -92,18 +114,10 @@ export function normalizeRuleEntry(entry) {
92
114
  }
93
115
 
94
116
  /**
95
- * Resolve the effective audit config: built-in defaults overlaid by a
96
- * discovered or explicitly-given JSON config file.
97
- * Rule options merge over the defaults for that rule; severity replaces.
98
- *
99
- * @param {string} cwd - Directory to search for config files
100
- * @param {string|null} explicitPath - Path passed via --config (wins over discovery)
101
- * @returns {{maxWarnings, rules: {[id]: {severity, options}}, configPath}}
102
- */
103
- /**
104
- * Walk up from `cwd` to the filesystem root looking for the shared
105
- * `.dependably-check` config file. Stops at the first hit, at a directory
106
- * containing a `.git` entry (the repo root), or at the filesystem root.
117
+ * Walk up from `cwd` to the filesystem root looking for a shared config file.
118
+ * At each level `.dependably` is preferred over the deprecated `.dependably-check`.
119
+ * Stops at the first hit, at a directory containing a `.git` entry (the repo
120
+ * root), or at the filesystem root.
107
121
  *
108
122
  * @param {string} cwd - Directory to start the search from
109
123
  * @returns {string|null} Absolute path to the shared config, or null when absent
@@ -111,8 +125,10 @@ export function normalizeRuleEntry(entry) {
111
125
  export function findSharedConfig(cwd = process.cwd()) {
112
126
  let dir = path.resolve(cwd);
113
127
  for (;;) {
114
- const candidate = path.join(dir, SHARED_CONFIG_FILENAME);
115
- if (fs.existsSync(candidate)) return candidate;
128
+ for (const name of SHARED_CONFIG_FILENAMES) {
129
+ const candidate = path.join(dir, name);
130
+ if (fs.existsSync(candidate)) return candidate;
131
+ }
116
132
 
117
133
  // Stop at the repo root: a directory containing `.git`.
118
134
  if (fs.existsSync(path.join(dir, '.git'))) return null;
@@ -123,38 +139,132 @@ export function findSharedConfig(cwd = process.cwd()) {
123
139
  }
124
140
  }
125
141
 
126
- // Union of `common.allowedRegistryHosts` and `npm.allowedRegistryHosts` from a
127
- // parsed shared config (deduped, common first). Other sections are ignored.
142
+ // Deprecation warnings for the selected shared-config file (§2.2/§2.3).
143
+ function filenameWarnings(sharedPath) {
144
+ if (!sharedPath) return [];
145
+ const dir = path.dirname(sharedPath);
146
+ const base = path.basename(sharedPath);
147
+ const warnings = [];
148
+ if (base === DEPRECATED_SHARED_CONFIG_FILENAME) {
149
+ if (fs.existsSync(path.join(dir, SHARED_CONFIG_FILENAME))) {
150
+ // Both present but findSharedConfig preferred canonical — should not reach
151
+ // here; keep the guard for direct callers.
152
+ warnings.push({ code: 'BOTH_FILES_PRESENT', message: `both ${SHARED_CONFIG_FILENAME} and ${DEPRECATED_SHARED_CONFIG_FILENAME} found in ${dir}; using ${SHARED_CONFIG_FILENAME}` });
153
+ } else {
154
+ warnings.push({ code: 'DEPRECATED_FILENAME', message: `${DEPRECATED_SHARED_CONFIG_FILENAME} is deprecated; rename it to ${SHARED_CONFIG_FILENAME}` });
155
+ }
156
+ } else if (fs.existsSync(path.join(dir, DEPRECATED_SHARED_CONFIG_FILENAME))) {
157
+ warnings.push({ code: 'BOTH_FILES_PRESENT', message: `both ${SHARED_CONFIG_FILENAME} and ${DEPRECATED_SHARED_CONFIG_FILENAME} found in ${dir}; using ${SHARED_CONFIG_FILENAME} (${DEPRECATED_SHARED_CONFIG_FILENAME} is ignored — delete it)` });
158
+ }
159
+ return warnings;
160
+ }
161
+
162
+ // Bare hostnames from a section's allowedRegistryHosts (lowercased, filtered).
163
+ function sectionHosts(section) {
164
+ const hosts = section && section.allowedRegistryHosts;
165
+ if (!Array.isArray(hosts)) return [];
166
+ return hosts.filter((h) => typeof h === 'string').map((h) => h.trim().toLowerCase()).filter(Boolean);
167
+ }
168
+
169
+ // Union of `common` and the npm-check section's allowedRegistryHosts (deduped,
170
+ // case-insensitive, common first). The canonical section is preferred; the `npm`
171
+ // alias is read only when the canonical section is absent.
128
172
  function collectSharedHosts(parsed) {
129
- const collect = (section) => {
130
- const hosts = section && section.allowedRegistryHosts;
131
- return Array.isArray(hosts) ? hosts.filter((h) => typeof h === 'string') : [];
132
- };
133
- return [...new Set([...collect(parsed.common), ...collect(parsed.npm)])];
173
+ const tool = parsed[SECTION_KEY] !== undefined ? parsed[SECTION_KEY] : parsed[DEPRECATED_SECTION_KEY];
174
+ return [...new Set([...sectionHosts(parsed && parsed.common), ...sectionHosts(tool)])];
134
175
  }
135
176
 
136
- // Pull npm-check's audit settings (`rules`, `maxWarnings`) out of a shared
137
- // `.dependably-check` object: the `common` section is the base, the `npm`
138
- // section overrides it. Mirrors how the other suite tools read their section.
139
- function extractSharedAuditSettings(parsed) {
140
- const pick = (section) => {
141
- const out = {};
142
- if (section && typeof section === 'object') {
143
- if (section.rules !== undefined) out.rules = section.rules;
144
- if (section.maxWarnings !== undefined) out.maxWarnings = section.maxWarnings;
177
+ // Union two arrays (ordinal dedupe), tolerating non-arrays.
178
+ function unionList(a, b) {
179
+ const out = [];
180
+ const seen = new Set();
181
+ for (const v of [...(Array.isArray(a) ? a : []), ...(Array.isArray(b) ? b : [])]) {
182
+ if (!seen.has(v)) { seen.add(v); out.push(v); }
183
+ }
184
+ return out;
185
+ }
186
+
187
+ // Merge the `rules` maps of two sections per rule-id: the tool entry for a given
188
+ // id replaces common's wholesale (no cross-section option deep-merge, §B.3).
189
+ function mergeRuleMaps(commonRules, toolRules) {
190
+ if (!commonRules && !toolRules) return undefined;
191
+ return { ...(commonRules && typeof commonRules === 'object' ? commonRules : {}),
192
+ ...(toolRules && typeof toolRules === 'object' ? toolRules : {}) };
193
+ }
194
+
195
+ // Warn about keys npm-check does not recognize inside a read section (§8).
196
+ function unknownKeyWarnings(section, label, warnings) {
197
+ if (!section || typeof section !== 'object') return;
198
+ for (const key of Object.keys(section)) {
199
+ if (!KNOWN_SECTION_KEYS.has(key)) {
200
+ warnings.push({ code: 'UNKNOWN_KEY', message: `unknown key "${label}.${key}" in shared config — ignoring` });
145
201
  }
146
- return out;
147
- };
148
- return { ...pick(parsed && parsed.common), ...pick(parsed && parsed.npm) };
202
+ }
149
203
  }
150
204
 
151
- // True when a parsed config is the shared `.dependably-check` shape (sectioned
152
- // by tool) rather than the legacy flat tool-config shape (top-level rules/maxWarnings).
205
+ /**
206
+ * Resolve npm-check's settings from a parsed shared-config object: merge `common`
207
+ * under the npm-check section per the single merge rule (§5). Returns the audit
208
+ * settings plus parsed exceptions and any warnings.
209
+ */
210
+ function resolveToolSection(parsed, warnings) {
211
+ const common = parsed && parsed.common;
212
+ const canonical = parsed && parsed[SECTION_KEY];
213
+ const alias = parsed && parsed[DEPRECATED_SECTION_KEY];
214
+ const tool = canonical !== undefined ? canonical : alias;
215
+ if (canonical === undefined && alias !== undefined) {
216
+ warnings.push({ code: 'DEPRECATED_ALIAS_SECTION', message: `section "${DEPRECATED_SECTION_KEY}" is deprecated; rename it to "${SECTION_KEY}"` });
217
+ } else if (canonical !== undefined && alias !== undefined) {
218
+ warnings.push({ code: 'DEPRECATED_ALIAS_SECTION', message: `both "${SECTION_KEY}" and "${DEPRECATED_SECTION_KEY}" sections present; using "${SECTION_KEY}"` });
219
+ }
220
+
221
+ unknownKeyWarnings(common, 'common', warnings);
222
+ unknownKeyWarnings(tool, SECTION_KEY, warnings);
223
+
224
+ const settings = {};
225
+ const rules = mergeRuleMaps(common && common.rules, tool && tool.rules);
226
+ if (rules) settings.rules = rules;
227
+
228
+ // Scalars: tool overrides common. failOn merges per key.
229
+ const failOn = pickFailOn(common && common.failOn, tool && tool.failOn);
230
+ if (failOn) settings.failOn = failOn;
231
+
232
+ const pickMax = (s) => (s && s.maxWarnings !== undefined ? s.maxWarnings : undefined);
233
+ const toolMax = pickMax(tool);
234
+ const commonMax = pickMax(common);
235
+ if (toolMax !== undefined) settings.maxWarnings = toolMax;
236
+ else if (commonMax !== undefined) settings.maxWarnings = commonMax;
237
+
238
+ settings.exclude = unionList(common && common.exclude, tool && tool.exclude);
239
+
240
+ // Exceptions: common (tolerant) + own section (strict selector/rule checks).
241
+ const commonEx = parseExceptions(common && common.exceptions, {
242
+ source: 'common', applicableSelectors: APPLICABLE_SELECTORS
243
+ });
244
+ const ownEx = parseExceptions(tool && tool.exceptions, {
245
+ source: 'own', applicableSelectors: APPLICABLE_SELECTORS, knownRules: KNOWN_RULES
246
+ });
247
+ settings.exceptions = [...commonEx, ...ownEx];
248
+
249
+ return settings;
250
+ }
251
+
252
+ // Merge two failOn objects per key (tool wins). Returns undefined if neither set.
253
+ function pickFailOn(commonFailOn, toolFailOn) {
254
+ const c = commonFailOn && typeof commonFailOn === 'object' ? commonFailOn : {};
255
+ const t = toolFailOn && typeof toolFailOn === 'object' ? toolFailOn : {};
256
+ const merged = { ...c, ...t };
257
+ if (merged.severity === undefined && merged.count === undefined) return undefined;
258
+ return merged;
259
+ }
260
+
261
+ // True when a parsed config is the shared (sectioned) shape rather than the
262
+ // legacy flat tool-config shape (top-level rules/maxWarnings).
153
263
  function isSharedShape(configPath, parsed) {
154
- if (path.basename(configPath) === SHARED_CONFIG_FILENAME) return true;
264
+ if (SHARED_CONFIG_FILENAMES.includes(path.basename(configPath))) return true;
155
265
  if (!parsed || typeof parsed !== 'object') return false;
156
266
  const hasToolKeys = 'rules' in parsed || 'maxWarnings' in parsed;
157
- const hasSharedSections = 'common' in parsed || 'npm' in parsed;
267
+ const hasSharedSections = 'common' in parsed || SECTION_KEY in parsed || DEPRECATED_SECTION_KEY in parsed;
158
268
  return !hasToolKeys && hasSharedSections;
159
269
  }
160
270
 
@@ -173,17 +283,35 @@ function readJsonConfig(configPath) {
173
283
  }
174
284
  }
175
285
 
286
+ // Validate the top-level shape + version of a shared-config object (§3, §8).
287
+ function validateSharedShape(parsed, sharedPath) {
288
+ if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
289
+ throw new AuditConfigError(`Shared config must be a JSON object: ${sharedPath}`, 'CONFIG_SHAPE', { sharedPath });
290
+ }
291
+ if (parsed.version !== undefined) {
292
+ if (typeof parsed.version !== 'number' || !Number.isInteger(parsed.version) || parsed.version > SUPPORTED_CONFIG_VERSION) {
293
+ throw new AuditConfigError(
294
+ `Unsupported .dependably version ${JSON.stringify(parsed.version)} (this build supports up to ${SUPPORTED_CONFIG_VERSION})`,
295
+ 'CONFIG_VERSION',
296
+ { sharedPath }
297
+ );
298
+ }
299
+ }
300
+ }
301
+
176
302
  /**
177
- * Read and parse the shared `.dependably-check` file, returning its registry-host
178
- * allowlist and npm-check audit settings (`rules`/`maxWarnings`). Other tool
179
- * sections and unknown keys are ignored.
303
+ * Read and parse the shared config file, returning npm-check's registry-host
304
+ * allowlist, audit settings (`rules`/`maxWarnings`/`failOn`/`exclude`),
305
+ * parsed exceptions, and any deprecation/unknown-key warnings.
180
306
  *
181
307
  * @param {string} cwd - Directory to start discovery from
182
- * @returns {{ allowedRegistryHosts: string[], sharedPath: string|null, auditSettings: object }}
308
+ * @returns {{ allowedRegistryHosts, sharedPath, auditSettings, exceptions, exclude, failOn, warnings }}
183
309
  */
184
310
  export function loadSharedConfig(cwd = process.cwd()) {
185
311
  const sharedPath = findSharedConfig(cwd);
186
- if (!sharedPath) return { allowedRegistryHosts: [], sharedPath: null, auditSettings: {} };
312
+ if (!sharedPath) {
313
+ return { allowedRegistryHosts: [], sharedPath: null, auditSettings: {}, exceptions: [], exclude: [], failOn: null, warnings: [] };
314
+ }
187
315
 
188
316
  let raw;
189
317
  try {
@@ -199,23 +327,31 @@ export function loadSharedConfig(cwd = process.cwd()) {
199
327
  throw new AuditConfigError(`Invalid JSON in ${sharedPath}: ${e.message}`, 'SHARED_CONFIG_PARSE', { sharedPath });
200
328
  }
201
329
 
330
+ validateSharedShape(parsed, sharedPath);
331
+ const warnings = filenameWarnings(sharedPath);
332
+ const settings = resolveToolSection(parsed, warnings);
333
+
202
334
  return {
203
335
  allowedRegistryHosts: collectSharedHosts(parsed),
204
336
  sharedPath,
205
- auditSettings: extractSharedAuditSettings(parsed)
337
+ auditSettings: { ...(settings.rules ? { rules: settings.rules } : {}), ...(settings.maxWarnings !== undefined ? { maxWarnings: settings.maxWarnings } : {}) },
338
+ exceptions: settings.exceptions,
339
+ exclude: settings.exclude,
340
+ failOn: settings.failOn || null,
341
+ warnings
206
342
  };
207
343
  }
208
344
 
209
345
  export function loadAuditConfig(cwd = process.cwd(), explicitPath = null) {
210
- // The shared `.dependably-check` (discovered by walking up to the repo root)
211
- // is the PRIMARY config source its `common`/`npm` sections supply the base
212
- // audit settings and the registry-host allowlist. A tool-specific
213
- // `.npm-checkrc.json` (or an explicit `--config`) overrides it.
346
+ // The shared `.dependably` (discovered by walking up to the repo root) is the
347
+ // PRIMARY config source. A tool-specific `.npm-checkrc.json` (or an explicit
348
+ // `--config`) overrides it.
214
349
  const shared = loadSharedConfig(cwd);
215
350
 
216
351
  let toolConfig = {};
217
352
  let configPath = null;
218
353
  let explicitSharedHosts = [];
354
+ let explicitExtras = null; // { exceptions, exclude, failOn } from an explicit shared-shape file
219
355
 
220
356
  if (explicitPath) {
221
357
  configPath = path.resolve(explicitPath);
@@ -224,17 +360,20 @@ export function loadAuditConfig(cwd = process.cwd(), explicitPath = null) {
224
360
  }
225
361
  const parsed = readJsonConfig(configPath);
226
362
  if (isSharedShape(configPath, parsed)) {
227
- // `--config` points at a `.dependably-check`: read npm-check's settings
228
- // from its common/npm sections and take its registry-host allowlist too.
229
- toolConfig = extractSharedAuditSettings(parsed);
363
+ validateSharedShape(parsed, configPath);
364
+ const warnings = [];
365
+ const settings = resolveToolSection(parsed, warnings);
366
+ shared.warnings.push(...warnings);
367
+ toolConfig = { ...(settings.rules ? { rules: settings.rules } : {}), ...(settings.maxWarnings !== undefined ? { maxWarnings: settings.maxWarnings } : {}) };
230
368
  explicitSharedHosts = collectSharedHosts(parsed);
369
+ explicitExtras = { exceptions: settings.exceptions, exclude: settings.exclude, failOn: settings.failOn || null };
231
370
  } else {
232
371
  // Legacy flat tool-config (.npm-checkrc.json shape) given explicitly.
233
372
  toolConfig = parsed;
234
373
  }
235
374
  } else {
236
375
  // Discover a tool-specific config in the working directory (fallback for
237
- // back-compat; the shared `.dependably-check` above is the primary source).
376
+ // back-compat; the shared `.dependably` above is the primary source).
238
377
  for (const name of CONFIG_FILENAMES) {
239
378
  const candidate = path.join(cwd, name);
240
379
  if (fs.existsSync(candidate)) {
@@ -249,13 +388,29 @@ export function loadAuditConfig(cwd = process.cwd(), explicitPath = null) {
249
388
  const userConfig = { ...shared.auditSettings, ...toolConfig };
250
389
  const config = mergeConfig(userConfig, configPath || shared.sharedPath);
251
390
 
252
- // Layer the shared `.dependably-check` hosts ADDITIVELY onto whatever
253
- // secure-resolved.allowedHosts resolved to (built-in default or a
254
- // tool-config replacement) — public npm always stays trusted.
391
+ // Layer the shared allowlist ADDITIVELY onto secure-resolved / no-remote-deps
392
+ // (public npm always stays trusted).
255
393
  const hosts = [...new Set([...shared.allowedRegistryHosts, ...explicitSharedHosts])];
256
394
  if (hosts.length > 0) {
257
395
  extendAllowedHosts(config, hosts);
258
396
  }
397
+
398
+ // failOn.count is the standard form of maxWarnings; a legacy maxWarnings in a
399
+ // tool-config still wins if it was set (it flowed through mergeConfig above).
400
+ const failOn = explicitExtras ? explicitExtras.failOn : shared.failOn;
401
+ if (failOn) {
402
+ if (failOn.count !== undefined && toolConfig.maxWarnings === undefined && shared.auditSettings.maxWarnings === undefined) {
403
+ if (typeof failOn.count !== 'number' || !Number.isInteger(failOn.count) || failOn.count < 0) {
404
+ throw new AuditConfigError(`failOn.count must be a non-negative integer, got: ${JSON.stringify(failOn.count)}`, 'INVALID_FAIL_ON');
405
+ }
406
+ config.maxWarnings = failOn.count;
407
+ }
408
+ config.failOnSeverity = failOn.severity || null;
409
+ }
410
+
411
+ config.exceptions = explicitExtras ? explicitExtras.exceptions : shared.exceptions;
412
+ config.exclude = explicitExtras ? explicitExtras.exclude : shared.exclude;
413
+ config.warnings = shared.warnings;
259
414
  config.sharedConfigPath = shared.sharedPath;
260
415
 
261
416
  return config;
@@ -265,11 +420,9 @@ export function loadAuditConfig(cwd = process.cwd(), explicitPath = null) {
265
420
  * Add the given hosts to every host-based rule's `allowedHosts`, deduplicated,
266
421
  * without replacing the existing entries. No-op for rules that are absent.
267
422
  *
268
- * BOTH `secure-resolved` (which registries are trusted for HTTPS resolution) and
269
- * `no-remote-deps` (which registry hosts count as a registry rather than a
270
- * remote/git dep) consult `allowedHosts`. The shared `.dependably-check`
271
- * allowlist must reach both — otherwise a private-registry project silences one
272
- * rule but still trips the other, breaking the documented `--fail-on` CI gate.
423
+ * BOTH `secure-resolved` and `no-remote-deps` consult `allowedHosts`; the shared
424
+ * allowlist must reach both or a private-registry project silences one rule but
425
+ * still trips the other.
273
426
  *
274
427
  * @param {object} config - A merged audit config (from mergeConfig)
275
428
  * @param {string[]} hosts - Bare hostnames to add to the allowlist