@dependably/npm-check 1.8.0 → 1.10.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
@@ -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.8.0",
3
+ "version": "1.10.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",
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": "3.5.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,
@@ -89,15 +106,16 @@
89
106
  "overrides": {
90
107
  "@babel/plugin-transform-modules-systemjs": "7.29.7",
91
108
  "@ungap/structured-clone": "1.3.1",
92
- "js-yaml": "4.2.0",
109
+ "brace-expansion": "5.0.9",
110
+ "js-yaml": "4.3.1",
93
111
  "picomatch": "4.0.4",
94
112
  "glob": {
95
113
  "minimatch": "3.1.5",
96
- "brace-expansion": "1.1.15"
114
+ "brace-expansion": "1.1.18"
97
115
  },
98
116
  "test-exclude": {
99
117
  "minimatch": "3.1.5",
100
- "brace-expansion": "1.1.15"
118
+ "brace-expansion": "1.1.18"
101
119
  }
102
120
  }
103
121
  }
@@ -16,7 +16,8 @@ export const CONFIG_FILENAMES = ['.npm-checkrc.json', 'npm-check.config.json'];
16
16
 
17
17
  // Shared, cross-tool config file (JSON, no extension) discovered by walking up
18
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).
19
+ // a deprecated alias kept for the migration window (config spec §7, at
20
+ // https://gitlab.northwardlabs.ca/moonlitlabs/dependably-spec).
20
21
  export const SHARED_CONFIG_FILENAME = '.dependably';
21
22
  export const DEPRECATED_SHARED_CONFIG_FILENAME = '.dependably-check';
22
23
  // Checked in this order at each directory level (canonical wins).
@@ -34,8 +35,9 @@ export const SUPPORTED_CONFIG_VERSION = 1;
34
35
  // own section but tolerated (ignored) in `common`.
35
36
  export const APPLICABLE_SELECTORS = ['package', 'id'];
36
37
 
37
- // Keys npm-check recognizes inside `common` / its own section. Unknown keys warn.
38
- const KNOWN_SECTION_KEYS = new Set([
38
+ // Keys npm-check recognizes inside its own section. An unrecognized key there warns;
39
+ // the same key in `common` is ignored, since it may belong to a sibling tool.
40
+ export const KNOWN_SECTION_KEYS = new Set([
39
41
  'rules', 'exceptions', 'exclude', 'failOn',
40
42
  'allowedRegistryHosts', 'allowedLocalFeeds', 'maxWarnings'
41
43
  ]);
@@ -58,6 +60,16 @@ export const DEFAULT_CONFIG = {
58
60
  'install-scripts': ['warn', { allow: [] }],
59
61
  'no-git-deps': 'warn',
60
62
  'no-remote-deps': ['warn', { allowedHosts: ['registry.npmjs.org', 'npm.pkg.github.com'] }],
63
+ // Lockfile portability, distinct from the trust question the two rules above
64
+ // ask. Empty `hosts` == off: pinning is a per-project decision (a project
65
+ // that genuinely installs from a private registry must not be flagged), and
66
+ // unlike `allowedRegistryHosts` this list is NOT unioned from shared config —
67
+ // a pin that only ever widens would not be a pin.
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 }],
61
73
  'pinned-versions': ['error', {
62
74
  sections: ['dependencies', 'devDependencies', 'optionalDependencies'],
63
75
  ignore: []
@@ -188,11 +200,26 @@ function unionList(a, b) {
188
200
  // id replaces common's wholesale (no cross-section option deep-merge, §B.3).
189
201
  function mergeRuleMaps(commonRules, toolRules) {
190
202
  if (!commonRules && !toolRules) return undefined;
191
- return { ...(commonRules && typeof commonRules === 'object' ? commonRules : {}),
192
- ...(toolRules && typeof toolRules === 'object' ? toolRules : {}) };
203
+
204
+ // A rule id in `common` that npm-check does not know belongs to a sibling tool, so it is
205
+ // dropped rather than merged (§8). Without this it reached mergeConfig's registry check and
206
+ // raised UNKNOWN_RULE, which made a shared config unusable the moment any sibling configured
207
+ // one of its own rules. Ids from the tool's own section are passed through untouched, so an
208
+ // unknown id there still errors — that one is a typo, not a sibling's.
209
+ const fromCommon = {};
210
+ if (commonRules && typeof commonRules === 'object') {
211
+ for (const [ruleId, value] of Object.entries(commonRules)) {
212
+ if (KNOWN_RULES.includes(ruleId)) fromCommon[ruleId] = value;
213
+ }
214
+ }
215
+
216
+ return { ...fromCommon, ...(toolRules && typeof toolRules === 'object' ? toolRules : {}) };
193
217
  }
194
218
 
195
- // Warn about keys npm-check does not recognize inside a read section (§8).
219
+ // Warn about keys npm-check does not recognize inside its own section (§8). `common`
220
+ // is deliberately not checked: it is shared with the sibling tools, so a key npm-check
221
+ // does not know there belongs to one of them, not to a typo. This matches how unknown
222
+ // rule ids are already treated — tolerated in `common`, an error in the own section.
196
223
  function unknownKeyWarnings(section, label, warnings) {
197
224
  if (!section || typeof section !== 'object') return;
198
225
  for (const key of Object.keys(section)) {
@@ -207,18 +234,42 @@ function unknownKeyWarnings(section, label, warnings) {
207
234
  * under the npm-check section per the single merge rule (§5). Returns the audit
208
235
  * settings plus parsed exceptions and any warnings.
209
236
  */
210
- function resolveToolSection(parsed, warnings) {
211
- 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) {
212
240
  const canonical = parsed && parsed[SECTION_KEY];
213
241
  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}"` });
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 });
219
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);
220
272
 
221
- unknownKeyWarnings(common, 'common', warnings);
222
273
  unknownKeyWarnings(tool, SECTION_KEY, warnings);
223
274
 
224
275
  const settings = {};
@@ -229,22 +280,11 @@ function resolveToolSection(parsed, warnings) {
229
280
  const failOn = pickFailOn(common && common.failOn, tool && tool.failOn);
230
281
  if (failOn) settings.failOn = failOn;
231
282
 
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;
283
+ const maxWarnings = pickMaxWarnings(common, tool);
284
+ if (maxWarnings !== undefined) settings.maxWarnings = maxWarnings;
237
285
 
238
286
  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];
287
+ settings.exceptions = mergeExceptions(common, tool);
248
288
 
249
289
  return settings;
250
290
  }
@@ -342,47 +382,68 @@ export function loadSharedConfig(cwd = process.cwd()) {
342
382
  };
343
383
  }
344
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
+
345
439
  export function loadAuditConfig(cwd = process.cwd(), explicitPath = null) {
346
440
  // The shared `.dependably` (discovered by walking up to the repo root) is the
347
441
  // PRIMARY config source. A tool-specific `.npm-checkrc.json` (or an explicit
348
442
  // `--config`) overrides it.
349
443
  const shared = loadSharedConfig(cwd);
350
444
 
351
- let toolConfig = {};
352
- let configPath = null;
353
- let explicitSharedHosts = [];
354
- let explicitExtras = null; // { exceptions, exclude, failOn } from an explicit shared-shape file
355
-
356
- if (explicitPath) {
357
- configPath = path.resolve(explicitPath);
358
- if (!fs.existsSync(configPath)) {
359
- throw new AuditConfigError(`Config file not found: ${configPath}`, 'CONFIG_NOT_FOUND');
360
- }
361
- const parsed = readJsonConfig(configPath);
362
- if (isSharedShape(configPath, 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 } : {}) };
368
- explicitSharedHosts = collectSharedHosts(parsed);
369
- explicitExtras = { exceptions: settings.exceptions, exclude: settings.exclude, failOn: settings.failOn || null };
370
- } else {
371
- // Legacy flat tool-config (.npm-checkrc.json shape) given explicitly.
372
- toolConfig = parsed;
373
- }
374
- } else {
375
- // Discover a tool-specific config in the working directory (fallback for
376
- // back-compat; the shared `.dependably` above is the primary source).
377
- for (const name of CONFIG_FILENAMES) {
378
- const candidate = path.join(cwd, name);
379
- if (fs.existsSync(candidate)) {
380
- configPath = candidate;
381
- toolConfig = readJsonConfig(candidate);
382
- break;
383
- }
384
- }
385
- }
445
+ const { configPath, toolConfig, explicitSharedHosts = [], explicitExtras = null } =
446
+ explicitPath ? loadExplicitConfig(explicitPath, shared) : discoverToolConfig(cwd);
386
447
 
387
448
  // Shared audit settings are the base; the tool-specific config overrides them.
388
449
  const userConfig = { ...shared.auditSettings, ...toolConfig };
@@ -395,18 +456,9 @@ export function loadAuditConfig(cwd = process.cwd(), explicitPath = null) {
395
456
  extendAllowedHosts(config, hosts);
396
457
  }
397
458
 
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
- }
459
+ const maxWarningsAlreadySet =
460
+ toolConfig.maxWarnings !== undefined || shared.auditSettings.maxWarnings !== undefined;
461
+ applyFailOn(config, explicitExtras ? explicitExtras.failOn : shared.failOn, maxWarningsAlreadySet);
410
462
 
411
463
  config.exceptions = explicitExtras ? explicitExtras.exceptions : shared.exceptions;
412
464
  config.exclude = explicitExtras ? explicitExtras.exclude : shared.exclude;