@dependably/npm-check 1.9.0 → 1.10.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/README.md CHANGED
@@ -54,8 +54,55 @@ for every command, flag, and exit code.
54
54
  - **Vulnerabilities & deprecations** — scan locked versions against the npm advisory endpoint and deprecation notices.
55
55
  - **Licenses** — validate SPDX expressions against an approved list.
56
56
  - **Pin / prune / unused** — lock ranges (incl. `overrides`) to exact versions, remove orphaned entries, flag unused deps.
57
+ - **Import facts** — report what the source tree imports (per-file imports, bindings, the module graph through `node_modules`, lockfile graphs) as a JSON document for another tool to consume. See below.
57
58
  - **pnpm** — read-only checks and config validation for `pnpm-lock.yaml`.
58
59
 
60
+ ## Import facts (`npm-check imports`)
61
+
62
+ `npm-check imports <dir>` exports what a JavaScript/TypeScript/Svelte tree
63
+ imports, as data for another tool. It is the same parse-only scan
64
+ [sbom-reach](https://github.com/dependably/sbom-reach) uses for its npm
65
+ reachability verdicts — the imports of every first-party file, the binding
66
+ names each site introduces and which of them the file actually references,
67
+ each site resolved to the installed copy it loads, the statically resolved
68
+ module graph *through* `node_modules`, and the dependency graph the lockfiles
69
+ record — published instead of judged. Nothing is written, nothing is gated: a
70
+ successful scan always exits `0`.
71
+
72
+ ```bash
73
+ npm-check imports ./src # the facts document, as JSON
74
+ npm-check imports ./src --format human # its summary
75
+ npm-check imports ./src --no-module-graph # first-party imports only
76
+ ```
77
+
78
+ npm-check reports **npm language facts only**. It knows nothing about purls,
79
+ SBOMs or vulnerabilities, and it draws no conclusion: whether a package is
80
+ *reachable* is a verdict, and verdicts belong to the consumer.
81
+
82
+ **Facts are not findings.** An import site has no severity — "lodash is
83
+ imported on line 3" is not a problem to fix — so squeezing it into the
84
+ findings envelope's `findings` array would be a lie, and would let `--fail-on`
85
+ gate CI on ordinary imports. `imports` therefore emits a **sibling document
86
+ type**: the same envelope identity every Dependably tool shares (`tool`,
87
+ `toolVersion`, `schemaVersion`, `target`, `summary`), with `findings` replaced
88
+ by the facts sections, and an explicit `documentType: "imports"`
89
+ discriminator so a consumer never has to guess the payload from whichever key
90
+ happens to be present. A document with no `documentType` is a findings
91
+ document (schema `1.0` predates the split, and the findings envelope is
92
+ unchanged). This is the same contract pycheck's `--imports` established for
93
+ Python.
94
+
95
+ The load-bearing part is `unanalyzable`: every file the scan could not read,
96
+ every `.svelte` file whose `<script>` extraction reported a problem, every
97
+ `node_modules` file the walk skipped, and a walk cut off by its file budget is
98
+ listed with a `kind` and a `reason`, so that an absence of evidence is only
99
+ ever read as a negative when the search actually ran. The command needs the
100
+ optional `typescript` peer dependency (any 5.6+ release) and exits `2` with
101
+ `TYPESCRIPT_MISSING` when it is not installed. The full document shape is in
102
+ the [CLI reference](https://github.com/dependably/npm-check/blob/main/docs/CLI.md#imports-command);
103
+ the library form (`@dependably/npm-check/facts`) is in the
104
+ [API guide](https://github.com/dependably/npm-check/blob/main/docs/API.md#import-facts-dependablynpm-checkfacts).
105
+
59
106
  ## Documentation
60
107
 
61
108
  - **[CLI reference](https://github.com/dependably/npm-check/blob/main/docs/CLI.md)** — all commands, flags, exit codes, JSON output
package/bin/cli.js CHANGED
@@ -25,6 +25,7 @@ import { loadAuditConfig, mergeConfig } from '../src/audit-config.js';
25
25
  import { runReport, formatReport } from '../src/report.js';
26
26
  import { prunePackages } from '../src/pruner.js';
27
27
  import { findUnusedDependencies } from '../src/usage-scanner.js';
28
+ import { buildFactsEnvelope } from '../src/schema.js';
28
29
 
29
30
  const argv = process.argv.slice(2);
30
31
 
@@ -49,6 +50,9 @@ Commands:
49
50
  check [file] Verify integrity hashes and licenses
50
51
  audit [file] Lint lockfile for best practices (non-zero exit on failure)
51
52
  unused [dir] Flag declared dependencies the application never imports
53
+ imports [dir] Report the tree's import facts (per-file imports, bindings,
54
+ the module graph through node_modules, lockfile graphs) as
55
+ a JSON document — data for another tool, never a gate
52
56
 
53
57
  Fix & transform (npm-only; mutate the lockfile with --write):
54
58
  fix [file] [--write] Run automated fixer with optional write
@@ -147,6 +151,16 @@ Unused Options:
147
151
  --include-dev Also check devDependencies (off by default)
148
152
  --format human|json Output format (default: human; json is machine-readable)
149
153
 
154
+ Imports Options:
155
+ --format json|human Output format (default: json — the facts document, with
156
+ documentType "imports"; human prints its summary)
157
+ --no-module-graph Do not follow imports through node_modules
158
+ --max-files N Stop the node_modules walk after N files (default: 25000)
159
+ --max-file-bytes N Skip (and report) node_modules files larger than N bytes
160
+ (default: 1500000)
161
+ (needs the optional \`typescript\` peer dependency; exits 2 with TYPESCRIPT_MISSING
162
+ when it is not installed. A successful scan always exits 0 — facts are not findings.)
163
+
150
164
  Audit Options:
151
165
  --config <file> Suite config (.dependably; .dependably-check is a
152
166
  deprecated alias), discovered by walking up to the
@@ -175,6 +189,7 @@ Examples:
175
189
  npm-check pin --write # Lock down ^/~ versions
176
190
  npm-check prune --write # Remove orphaned lockfile entries
177
191
  npm-check unused # Flag never-imported dependencies
192
+ npm-check imports ./src > imports.json # Import facts for another tool to consume
178
193
  npm-check audit # Lint with default rules
179
194
  npm-check audit --fail-on count=0 --format json # Any warning fails the run
180
195
  npm-check audit --rule pinned-versions:error
@@ -325,6 +340,7 @@ function parseFormatFlag(allowed, fallback, code = 2) {
325
340
  const VALUED_OPTIONS = new Set([
326
341
  '--format', '--config', '--fail-on', '--concurrency', '--timeout',
327
342
  '--registry', '--licenses-csv', '--check', '--rule', '--keep',
343
+ '--max-files', '--max-file-bytes',
328
344
  // deprecated valued aliases (still parsed)
329
345
  '--min-severity', '--max-warnings'
330
346
  ]);
@@ -332,7 +348,7 @@ const BOOLEAN_OPTIONS = new Set([
332
348
  '--offline', '--allow-unresolved',
333
349
  '--no-integrity', '--no-vuln', '--no-deprecated', '--no-license',
334
350
  '--include-dev', '--include-peer', '--write', '--local-fallback', '--verbose',
335
- '--show-suppressed',
351
+ '--show-suppressed', '--no-module-graph',
336
352
  '--version', '--help', '-h',
337
353
  // deprecated boolean aliases (still parsed)
338
354
  '--strict', '--fail-on-deprecated'
@@ -993,6 +1009,81 @@ function runUnusedCommand() {
993
1009
  }
994
1010
  }
995
1011
 
1012
+ // `imports` is a data report, not a check: it emits the import-facts document
1013
+ // (`documentType: "imports"` — language facts only, no verdicts, no
1014
+ // severities), so it defaults to `--format json`, exits 0 on every successful
1015
+ // scan, and NEVER exits 1 — there is nothing to gate on. Exit 2 is the usage
1016
+ // tier: a missing target, a bad flag value, or the optional `typescript` peer
1017
+ // being absent (`TYPESCRIPT_MISSING`). The facts modules are imported lazily
1018
+ // so the lockfile-only commands never load the TypeScript compiler.
1019
+ async function runImportsCommand() {
1020
+ const dir = getDirArg();
1021
+ const target = positionals()[0] ?? '.';
1022
+ const format = parseFormatFlag(['json', 'human'], 'json');
1023
+ const moduleGraph = !argv.includes('--no-module-graph');
1024
+ const maxFiles = parsePositiveIntFlag('--max-files', undefined, '--max-files');
1025
+ const maxFileBytes = parsePositiveIntFlag('--max-file-bytes', undefined, '--max-file-bytes');
1026
+ if (!fs.existsSync(dir) || !fs.statSync(dir).isDirectory()) {
1027
+ console.error(`Error: Directory not found: ${dir}`);
1028
+ process.exit(2);
1029
+ }
1030
+
1031
+ const { collectImportFacts, factsDocument, FactsError } = await import('../src/facts/index.js');
1032
+ let facts;
1033
+ try {
1034
+ facts = collectImportFacts(dir, {
1035
+ moduleGraph,
1036
+ ...(maxFiles !== undefined ? { maxFiles } : {}),
1037
+ ...(maxFileBytes !== undefined ? { maxFileBytes } : {})
1038
+ });
1039
+ } catch (error) {
1040
+ if (error instanceof FactsError && error.code === 'TYPESCRIPT_MISSING') {
1041
+ console.error(`Error: ${error.code}: ${error.message}`);
1042
+ process.exit(2);
1043
+ }
1044
+ throw error;
1045
+ }
1046
+
1047
+ const { summary, ...body } = factsDocument(facts, { exitCode: 0 });
1048
+ if (format === 'json') {
1049
+ // The facts envelope is the ONLY thing on stdout in json mode.
1050
+ console.log(JSON.stringify(buildFactsEnvelope({ target, summary, body }), null, 2));
1051
+ return;
1052
+ }
1053
+ printImportsSummary(target, summary, body);
1054
+ }
1055
+
1056
+ // The human rendering of the facts document is its summary — the document
1057
+ // itself is data for another tool, and a page of import sites is not
1058
+ // something a person reads at a terminal.
1059
+ function printImportsSummary(target, summary, body) {
1060
+ console.log(`\nImport facts for ${target}`);
1061
+ console.log(` ${summary.scanned} first-party source file(s) found, ${summary.analyzed} analyzed, ${summary.unanalyzable} unanalyzable entr${summary.unanalyzable === 1 ? 'y' : 'ies'}`);
1062
+ const dynamic = body.imports.reduce((n, f) => n + f.dynamicUnknown, 0);
1063
+ console.log(` ${summary.imports} import site(s); ${dynamic} require()/import() call(s) with non-literal arguments could not be attributed`);
1064
+ const mg = body.moduleGraph;
1065
+ if (!mg.enabled) {
1066
+ console.log(' module graph: not walked (--no-module-graph)');
1067
+ } else if (mg.nodeModulesMissing) {
1068
+ console.log(` module graph: node_modules is not installed — ${mg.unresolved} import(s) unresolved, nothing followed`);
1069
+ } else {
1070
+ const truncated = mg.truncated ? ' (truncated at the file budget)' : '';
1071
+ console.log(` module graph: ${mg.filesParsed} node_modules file(s) parsed, ${summary.moduleGraph.reached} installed package copy(ies) reached, ${mg.unresolved} import(s) unresolved${truncated}`);
1072
+ if (mg.weakPackages.length > 0) {
1073
+ console.log(` ${mg.weakPackages.length} reached package(s) load modules dynamically or were not fully parsed: ${mg.weakPackages.slice(0, 5).join(', ')}${mg.weakPackages.length > 5 ? ', …' : ''}`);
1074
+ }
1075
+ }
1076
+ const lock = body.lockfile;
1077
+ if (lock.files.length > 0) {
1078
+ console.log(` lockfile graph: ${lock.packages.length} package(s), ${lock.edges.length} edge(s) from ${lock.files.join(', ')}`);
1079
+ }
1080
+ for (const d of [...body.workspace.diagnostics, ...lock.diagnostics]) console.log(` note: ${d}`);
1081
+ if (body.unanalyzable.length > 0) {
1082
+ console.log('\n Not analyzed (absence of evidence from these is not a negative):');
1083
+ for (const u of body.unanalyzable) console.log(` • ${u.file} [${u.kind}] ${u.reason}`);
1084
+ }
1085
+ }
1086
+
996
1087
  // Print .dependably config notices (deprecated filename/section, unknown keys)
997
1088
  // to stderr. Never affects exit codes or the JSON payload on stdout.
998
1089
  function emitConfigWarnings(warnings, format) {
@@ -1559,6 +1650,7 @@ const COMMAND_HANDLERS = {
1559
1650
  pin: () => runPinCommand(),
1560
1651
  prune: () => runPruneCommand(),
1561
1652
  unused: () => runUnusedCommand(),
1653
+ imports: () => runImportsCommand(),
1562
1654
  audit: () => runAuditCommand(),
1563
1655
  'upgrade-hashes': () => runUpgradeHashesCommand(),
1564
1656
  dedupe: () => runDedupeCommand(),
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@dependably/npm-check",
3
- "version": "1.9.0",
3
+ "version": "1.10.1",
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",
7
7
  "exports": {
8
8
  ".": "./src/index.js",
9
+ "./facts": {
10
+ "types": "./src/facts/types.d.ts",
11
+ "default": "./src/facts/index.js"
12
+ },
9
13
  "./package.json": "./package.json"
10
14
  },
11
15
  "bin": {
@@ -32,7 +36,8 @@
32
36
  "docker:test:node24": "docker-compose -f tests/integration/docker/docker-compose.yml run --rm test-node24-npm11",
33
37
  "docker:test:all": "docker-compose -f tests/integration/docker/docker-compose.yml up --abort-on-container-exit",
34
38
  "lint": "eslint .",
35
- "sbom": "cyclonedx-npm --output-format json --output-file sbom.json",
39
+ "typecheck": "tsc -p tsconfig.facts.json --noEmit",
40
+ "sbom":"cyclonedx-npm --output-format json --output-file sbom.json",
36
41
  "sbom:prod": "cyclonedx-npm --omit dev --output-format json --output-file sbom-prod.json",
37
42
  "prepublishOnly": "npm run test && npm run lint"
38
43
  },
@@ -69,6 +74,8 @@
69
74
  "npm": ">=10.0.0"
70
75
  },
71
76
  "dependencies": {
77
+ "fast-glob": "3.3.3",
78
+ "ignore": "7.0.5",
72
79
  "yaml": "2.9.0"
73
80
  },
74
81
  "devDependencies": {
@@ -76,11 +83,21 @@
76
83
  "@babel/preset-env": "7.28.6",
77
84
  "@cyclonedx/cyclonedx-npm": "5.0.0",
78
85
  "@eslint/js": "10.0.1",
86
+ "@types/node": "22.20.1",
79
87
  "babel-jest": "30.2.0",
80
88
  "eslint": "10.5.0",
81
89
  "globals": "17.6.0",
82
90
  "jest": "29.7.0",
83
- "sonarqube-scanner": "5.0.0"
91
+ "sonarqube-scanner": "5.0.0",
92
+ "typescript": "5.9.3"
93
+ },
94
+ "peerDependencies": {
95
+ "typescript": ">=5.6"
96
+ },
97
+ "peerDependenciesMeta": {
98
+ "typescript": {
99
+ "optional": true
100
+ }
84
101
  },
85
102
  "allowScripts": {
86
103
  "fsevents": true,
@@ -66,6 +66,10 @@ export const DEFAULT_CONFIG = {
66
66
  // unlike `allowedRegistryHosts` this list is NOT unioned from shared config —
67
67
  // a pin that only ever widens would not be a pin.
68
68
  'resolved-registry-pin': ['error', { hosts: [] }],
69
+ // 3 days: stricter than pnpm v11's own 1-day default and the ecosystem's
70
+ // 1-day baseline, deliberately — this tool is prescriptive. Lower it per
71
+ // project via the rule's `minDays` option.
72
+ 'min-release-age': ['warn', { minDays: 3 }],
69
73
  'pinned-versions': ['error', {
70
74
  sections: ['dependencies', 'devDependencies', 'optionalDependencies'],
71
75
  ignore: []
@@ -230,16 +234,41 @@ function unknownKeyWarnings(section, label, warnings) {
230
234
  * under the npm-check section per the single merge rule (§5). Returns the audit
231
235
  * settings plus parsed exceptions and any warnings.
232
236
  */
233
- function resolveToolSection(parsed, warnings) {
234
- const common = parsed && parsed.common;
237
+ // Pick the tool's own section, preferring the canonical key over the deprecated
238
+ // alias and warning about a deprecated or duplicated section.
239
+ function pickToolSection(parsed, warnings) {
235
240
  const canonical = parsed && parsed[SECTION_KEY];
236
241
  const alias = parsed && parsed[DEPRECATED_SECTION_KEY];
237
- const tool = canonical !== undefined ? canonical : alias;
238
- if (canonical === undefined && alias !== undefined) {
239
- warnings.push({ code: 'DEPRECATED_ALIAS_SECTION', message: `section "${DEPRECATED_SECTION_KEY}" is deprecated; rename it to "${SECTION_KEY}"` });
240
- } else if (canonical !== undefined && alias !== undefined) {
241
- warnings.push({ code: 'DEPRECATED_ALIAS_SECTION', message: `both "${SECTION_KEY}" and "${DEPRECATED_SECTION_KEY}" sections present; using "${SECTION_KEY}"` });
242
+ if (alias !== undefined) {
243
+ const message = canonical === undefined
244
+ ? `section "${DEPRECATED_SECTION_KEY}" is deprecated; rename it to "${SECTION_KEY}"`
245
+ : `both "${SECTION_KEY}" and "${DEPRECATED_SECTION_KEY}" sections present; using "${SECTION_KEY}"`;
246
+ warnings.push({ code: 'DEPRECATED_ALIAS_SECTION', message });
242
247
  }
248
+ return canonical !== undefined ? canonical : alias;
249
+ }
250
+
251
+ // maxWarnings is a scalar: the tool section overrides common.
252
+ function pickMaxWarnings(common, tool) {
253
+ const of = (s) => (s && s.maxWarnings !== undefined ? s.maxWarnings : undefined);
254
+ const toolMax = of(tool);
255
+ return toolMax !== undefined ? toolMax : of(common);
256
+ }
257
+
258
+ // Exceptions: common (tolerant) + own section (strict selector/rule checks).
259
+ function mergeExceptions(common, tool) {
260
+ const commonEx = parseExceptions(common && common.exceptions, {
261
+ source: 'common', applicableSelectors: APPLICABLE_SELECTORS
262
+ });
263
+ const ownEx = parseExceptions(tool && tool.exceptions, {
264
+ source: 'own', applicableSelectors: APPLICABLE_SELECTORS, knownRules: KNOWN_RULES
265
+ });
266
+ return [...commonEx, ...ownEx];
267
+ }
268
+
269
+ function resolveToolSection(parsed, warnings) {
270
+ const common = parsed && parsed.common;
271
+ const tool = pickToolSection(parsed, warnings);
243
272
 
244
273
  unknownKeyWarnings(tool, SECTION_KEY, warnings);
245
274
 
@@ -251,22 +280,11 @@ function resolveToolSection(parsed, warnings) {
251
280
  const failOn = pickFailOn(common && common.failOn, tool && tool.failOn);
252
281
  if (failOn) settings.failOn = failOn;
253
282
 
254
- const pickMax = (s) => (s && s.maxWarnings !== undefined ? s.maxWarnings : undefined);
255
- const toolMax = pickMax(tool);
256
- const commonMax = pickMax(common);
257
- if (toolMax !== undefined) settings.maxWarnings = toolMax;
258
- else if (commonMax !== undefined) settings.maxWarnings = commonMax;
283
+ const maxWarnings = pickMaxWarnings(common, tool);
284
+ if (maxWarnings !== undefined) settings.maxWarnings = maxWarnings;
259
285
 
260
286
  settings.exclude = unionList(common && common.exclude, tool && tool.exclude);
261
-
262
- // Exceptions: common (tolerant) + own section (strict selector/rule checks).
263
- const commonEx = parseExceptions(common && common.exceptions, {
264
- source: 'common', applicableSelectors: APPLICABLE_SELECTORS
265
- });
266
- const ownEx = parseExceptions(tool && tool.exceptions, {
267
- source: 'own', applicableSelectors: APPLICABLE_SELECTORS, knownRules: KNOWN_RULES
268
- });
269
- settings.exceptions = [...commonEx, ...ownEx];
287
+ settings.exceptions = mergeExceptions(common, tool);
270
288
 
271
289
  return settings;
272
290
  }
@@ -364,47 +382,68 @@ export function loadSharedConfig(cwd = process.cwd()) {
364
382
  };
365
383
  }
366
384
 
385
+ // An explicit `--config` file: either the shared (sectioned) shape or a legacy
386
+ // flat tool-config. Fills the same slots the discovery path does.
387
+ function loadExplicitConfig(explicitPath, shared) {
388
+ const configPath = path.resolve(explicitPath);
389
+ if (!fs.existsSync(configPath)) {
390
+ throw new AuditConfigError(`Config file not found: ${configPath}`, 'CONFIG_NOT_FOUND');
391
+ }
392
+ const parsed = readJsonConfig(configPath);
393
+ if (!isSharedShape(configPath, parsed)) {
394
+ // Legacy flat tool-config (.npm-checkrc.json shape) given explicitly.
395
+ return { configPath, toolConfig: parsed };
396
+ }
397
+
398
+ validateSharedShape(parsed, configPath);
399
+ const warnings = [];
400
+ const settings = resolveToolSection(parsed, warnings);
401
+ shared.warnings.push(...warnings);
402
+ return {
403
+ configPath,
404
+ toolConfig: {
405
+ ...(settings.rules ? { rules: settings.rules } : {}),
406
+ ...(settings.maxWarnings !== undefined ? { maxWarnings: settings.maxWarnings } : {})
407
+ },
408
+ explicitSharedHosts: collectSharedHosts(parsed),
409
+ // { exceptions, exclude, failOn } from an explicit shared-shape file
410
+ explicitExtras: { exceptions: settings.exceptions, exclude: settings.exclude, failOn: settings.failOn || null }
411
+ };
412
+ }
413
+
414
+ // Discover a tool-specific config in the working directory (fallback for
415
+ // back-compat; the shared `.dependably` is the primary source).
416
+ function discoverToolConfig(cwd) {
417
+ for (const name of CONFIG_FILENAMES) {
418
+ const candidate = path.join(cwd, name);
419
+ if (fs.existsSync(candidate)) {
420
+ return { configPath: candidate, toolConfig: readJsonConfig(candidate) };
421
+ }
422
+ }
423
+ return { configPath: null, toolConfig: {} };
424
+ }
425
+
426
+ // failOn.count is the standard form of maxWarnings; a legacy maxWarnings in a
427
+ // tool-config still wins if it was set (it flowed through mergeConfig).
428
+ function applyFailOn(config, failOn, maxWarningsAlreadySet) {
429
+ if (!failOn) return;
430
+ if (failOn.count !== undefined && !maxWarningsAlreadySet) {
431
+ if (typeof failOn.count !== 'number' || !Number.isInteger(failOn.count) || failOn.count < 0) {
432
+ throw new AuditConfigError(`failOn.count must be a non-negative integer, got: ${JSON.stringify(failOn.count)}`, 'INVALID_FAIL_ON');
433
+ }
434
+ config.maxWarnings = failOn.count;
435
+ }
436
+ config.failOnSeverity = failOn.severity || null;
437
+ }
438
+
367
439
  export function loadAuditConfig(cwd = process.cwd(), explicitPath = null) {
368
440
  // The shared `.dependably` (discovered by walking up to the repo root) is the
369
441
  // PRIMARY config source. A tool-specific `.npm-checkrc.json` (or an explicit
370
442
  // `--config`) overrides it.
371
443
  const shared = loadSharedConfig(cwd);
372
444
 
373
- let toolConfig = {};
374
- let configPath = null;
375
- let explicitSharedHosts = [];
376
- let explicitExtras = null; // { exceptions, exclude, failOn } from an explicit shared-shape file
377
-
378
- if (explicitPath) {
379
- configPath = path.resolve(explicitPath);
380
- if (!fs.existsSync(configPath)) {
381
- throw new AuditConfigError(`Config file not found: ${configPath}`, 'CONFIG_NOT_FOUND');
382
- }
383
- const parsed = readJsonConfig(configPath);
384
- if (isSharedShape(configPath, parsed)) {
385
- validateSharedShape(parsed, configPath);
386
- const warnings = [];
387
- const settings = resolveToolSection(parsed, warnings);
388
- shared.warnings.push(...warnings);
389
- toolConfig = { ...(settings.rules ? { rules: settings.rules } : {}), ...(settings.maxWarnings !== undefined ? { maxWarnings: settings.maxWarnings } : {}) };
390
- explicitSharedHosts = collectSharedHosts(parsed);
391
- explicitExtras = { exceptions: settings.exceptions, exclude: settings.exclude, failOn: settings.failOn || null };
392
- } else {
393
- // Legacy flat tool-config (.npm-checkrc.json shape) given explicitly.
394
- toolConfig = parsed;
395
- }
396
- } else {
397
- // Discover a tool-specific config in the working directory (fallback for
398
- // back-compat; the shared `.dependably` above is the primary source).
399
- for (const name of CONFIG_FILENAMES) {
400
- const candidate = path.join(cwd, name);
401
- if (fs.existsSync(candidate)) {
402
- configPath = candidate;
403
- toolConfig = readJsonConfig(candidate);
404
- break;
405
- }
406
- }
407
- }
445
+ const { configPath, toolConfig, explicitSharedHosts = [], explicitExtras = null } =
446
+ explicitPath ? loadExplicitConfig(explicitPath, shared) : discoverToolConfig(cwd);
408
447
 
409
448
  // Shared audit settings are the base; the tool-specific config overrides them.
410
449
  const userConfig = { ...shared.auditSettings, ...toolConfig };
@@ -417,18 +456,9 @@ export function loadAuditConfig(cwd = process.cwd(), explicitPath = null) {
417
456
  extendAllowedHosts(config, hosts);
418
457
  }
419
458
 
420
- // failOn.count is the standard form of maxWarnings; a legacy maxWarnings in a
421
- // tool-config still wins if it was set (it flowed through mergeConfig above).
422
- const failOn = explicitExtras ? explicitExtras.failOn : shared.failOn;
423
- if (failOn) {
424
- if (failOn.count !== undefined && toolConfig.maxWarnings === undefined && shared.auditSettings.maxWarnings === undefined) {
425
- if (typeof failOn.count !== 'number' || !Number.isInteger(failOn.count) || failOn.count < 0) {
426
- throw new AuditConfigError(`failOn.count must be a non-negative integer, got: ${JSON.stringify(failOn.count)}`, 'INVALID_FAIL_ON');
427
- }
428
- config.maxWarnings = failOn.count;
429
- }
430
- config.failOnSeverity = failOn.severity || null;
431
- }
459
+ const maxWarningsAlreadySet =
460
+ toolConfig.maxWarnings !== undefined || shared.auditSettings.maxWarnings !== undefined;
461
+ applyFailOn(config, explicitExtras ? explicitExtras.failOn : shared.failOn, maxWarningsAlreadySet);
432
462
 
433
463
  config.exceptions = explicitExtras ? explicitExtras.exceptions : shared.exceptions;
434
464
  config.exclude = explicitExtras ? explicitExtras.exclude : shared.exclude;