mandrel-platform 1.2.0 → 1.4.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
@@ -51,7 +51,7 @@ aggregator context, and the pin-by-tag/SHA versioning model.
51
51
  | [`deploy-cloudflare.yml`](docs/reusable-workflows.md#deploy-cloudflareyml) | Defence-in-depth Cloudflare deploy with a frozen deploy-secret allowlist. |
52
52
  | [`secret-scan-push.yml`](docs/reusable-workflows.md#secret-scan-pushyml) | Full-history gitleaks secret scan on push to the default branch. |
53
53
  | [`release-automation.yml`](docs/reusable-workflows.md#release-automationyml) | Conventional-commit release lifecycle (version bump + `CHANGELOG.md` + tag) via release-please. |
54
- | [`codeql.yml`](docs/reusable-workflows.md#codeqlyml) | CodeQL SAST analysis — dual-mode: runs on this repo's push/PR/schedule **and** is `workflow_call`-consumable. |
54
+ | [`codeql.yml`](docs/reusable-workflows.md#codeqlyml) | CodeQL SAST analysis — dual-mode: runs on this repo's weekly schedule **and** is `workflow_call`-consumable (this repo's `ci.yml` calls it as a merge-blocking `code-scanning` job). |
55
55
 
56
56
  > `smoke-dispatch.yml` is a **platform-internal** cross-repo smoke trigger
57
57
  > (`push` / `workflow_dispatch`, not `workflow_call`). It appears in the
@@ -177,13 +177,19 @@ export default config;
177
177
 
178
178
  #### `stryker.base.json`
179
179
 
180
- Shared Stryker mutation-testing defaults (pnpm package manager,
181
- `perTest` coverage analysis, HTML + clear-text + progress reporters,
182
- `ignoreStatic`, a 60 s timeout, and high/low/break thresholds). Stryker's
183
- `extends` resolves a **local JSON path**, not an npm-package specifier, so
184
- the working mechanism is a `stryker.config.mjs` (or `.js` when
185
- `"type": "module"`) module that imports the base JSON and spreads it, then
186
- pins the test runner and mutate set:
180
+ Shared Stryker mutation-testing defaults: pnpm package manager, `perTest`
181
+ coverage analysis, HTML + clear-text + progress reporters, `ignoreStatic`,
182
+ `disableBail: true`, the timeout budget a bail-free run needs
183
+ (`timeoutMS: 120000` 120 s plus `timeoutFactor: 3` and
184
+ `dryRunTimeoutMinutes: 15`), and high/low/break thresholds.
185
+
186
+ Stryker has **no `extends` option at all** — its config reader loads exactly
187
+ one config file and merges CLI arguments over it, and `extends` is not among
188
+ the top-level properties its schema accepts, so a config that declares one is
189
+ reported as an unknown option and otherwise ignored. The working mechanism is
190
+ therefore a `stryker.config.mjs` (or `.js` when `"type": "module"`) module
191
+ that imports the base JSON **by package specifier** and spreads it, then pins
192
+ the test runner and mutate set:
187
193
 
188
194
  ```js
189
195
  // stryker.config.mjs — import the base and spread it
@@ -197,6 +203,12 @@ export default {
197
203
  };
198
204
  ```
199
205
 
206
+ Adopting the base is not a drop-in bump: `disableBail: true` changes what the
207
+ mutation score *means*, so any baseline captured under bail has to be
208
+ re-derived, and the base's `break: 50` is a fleet floor of last resort rather
209
+ than a calibrated target. Both are covered in
210
+ [Adopting the bail-free Stryker base](docs/patterns.md#4a-adopting-the-bail-free-stryker-base).
211
+
200
212
  #### `commitlint.base.mjs`
201
213
 
202
214
  Single-sources the conventional-commit **type-enum** — the eleven types
@@ -480,10 +492,17 @@ app.use("*", createHonoRateLimit({ limit: 100, windowMs: 60_000 }));
480
492
 
481
493
  ### `scripts/audit-check.mjs`
482
494
 
483
- CVE gate script. Runs `pnpm audit --prod` and blocks on any **unsuppressed**
484
- High or Critical vulnerability in the production dependency graph. This is the
485
- stricter athportal/swarm-os policy: all unsuppressed High/Critical are
486
- blocking, not just fixable ones.
495
+ Dependency gate script with **two independent blocking conditions**. It exits
496
+ non-zero when *either* fires a clean CVE scan does not excuse an unbounded
497
+ override, and vice versa:
498
+
499
+ 1. **CVE gate.** Runs `pnpm audit --prod` and blocks on any **unsuppressed**
500
+ High or Critical vulnerability in the production dependency graph. This is
501
+ the stricter athportal/swarm-os policy: all unsuppressed High/Critical are
502
+ blocking, not just fixable ones.
503
+ 2. **Unbounded-override lint.** Blocks on any dependency override written
504
+ without an upper bound — **independently of the CVE scan, and with zero
505
+ CVEs present**. It runs *first*, before `pnpm audit` is invoked at all.
487
506
 
488
507
  Known/accepted CVEs are suppressed via a **dated, self-expiring allowlist**
489
508
  (`audit-allowlist.json` in the project root). Expired entries are treated as
@@ -504,6 +523,44 @@ Or copy the script into your repo's `scripts/` directory when you need
504
523
  local customization (and pin a semver range on `mandrel-platform` so drift
505
524
  is detected by Renovate).
506
525
 
526
+ **Flags:**
527
+
528
+ | Flag | Default | Purpose |
529
+ | ---- | ------- | ------- |
530
+ | `--allowlist <path>` | `audit-allowlist.json` in the invoking directory | Where to read the dated CVE allowlist from. An absent file means "no suppressions". |
531
+ | `--package-json <path>` | `package.json` in the invoking directory | Which manifest the unbounded-override lint reads. Point it at a workspace member's manifest to lint that package instead of the root. An absent file skips the lint (the audit still runs). |
532
+
533
+ **Unbounded-override lint:**
534
+
535
+ An override *rewrites* a transitive dependent's declared range, so whatever is
536
+ written there is the only thing standing between your tree and the next release
537
+ of that package. Written open-endedly, the committed lockfile becomes the sole
538
+ pin — and the moment anything re-resolves (a fresh install, a lockfile-less CI
539
+ leg, a dependent's own bump) the newest release wins and can cross a major.
540
+ Nothing else in the toolchain lints for this, so the gate names each offending
541
+ override and its bound, then exits 1.
542
+
543
+ Every override field is checked — `overrides`, `resolutions`, and
544
+ `pnpm.overrides` — including dependent-scoped nested objects, which are
545
+ reported by their full path (`overrides.some-dep.left-pad`).
546
+
547
+ | Bound | Verdict | Why |
548
+ | ----- | ------- | --- |
549
+ | `1.2.3`, `=1.2.3` | ✅ bounded | Exact pin. |
550
+ | `^1.2.3`, `~1.2.3` | ✅ bounded | Cannot cross a major on its own. |
551
+ | `1.2.x`, `1.*` | ✅ bounded | The major is fixed; only lower positions float. |
552
+ | `>=1.2.3 <2.0.0`, `1.2.3 - 2.0.0` | ✅ bounded | Carries an explicit upper bound. |
553
+ | `npm:other-pkg@^1.2.3` | ✅ bounded | An alias is judged on the range it carries. |
554
+ | `>=1.2.3`, `>1.2.3` | ❌ blocking | A bare lower bound; the next major satisfies it. |
555
+ | `*`, `x`, `x.x`, `x.x.x`, `*.*.*` | ❌ blocking | npm reads a wildcard major as "any version". |
556
+ | `latest`, `next` | ❌ blocking | A dist-tag pins nothing. |
557
+ | `github:owner/repo`, `git+https://…`, `workspace:*`, `file:../pkg` | ❌ blocking | Non-registry specifiers re-resolve to whatever the source holds at install time. A git URL carrying an explicit `#semver:<range>` fragment is judged on that range instead. |
558
+ | `^1.0.0 \|\| >=2.0.0` | ❌ blocking | A `\|\|` union is only as bounded as its loosest arm. |
559
+
560
+ To fix a blocking override, give the bound an upper limit — `"^1.2.3"`,
561
+ `"~1.2.3"`, or `">=1.2.3 <2.0.0"`. There is no allowlist for this check: the
562
+ allowlist suppresses CVEs only.
563
+
507
564
  **Allowlist format (`audit-allowlist.json`):**
508
565
 
509
566
  ```jsonc
@@ -1,12 +1,17 @@
1
1
  {
2
2
  "$schema": "https://raw.githubusercontent.com/stryker-mutator/stryker-js/master/packages/api/schema/stryker-core.json",
3
- "_comment": "Shared Stryker base for mandrel-platform consumers. Consumers extend via stryker.config.json's `extends` (string or array): { \"extends\": [\"mandrel-platform/stryker.base.json\"], \"testRunner\": \"vitest\", \"mutate\": [\"src/**/*.ts\"] }. testRunner, mutate, and per-repo thresholds stay consumer-tunable.",
3
+ "_comment": "Shared Stryker base for mandrel-platform consumers. Stryker has NO `extends` option — its config reader loads exactly one config file and merges CLI arguments over it, and `extends` is not among the top-level properties its schema accepts, so a config declaring one is reported as an unknown option and otherwise ignored. Adopt this base by importing it by package specifier and spreading it into a `stryker.config.mjs`; the single worked example lives in the mandrel-platform README under `stryker.base.json`. testRunner, mutate, and per-repo thresholds stay consumer-tunable. Asserted by scripts/stryker-base-config.test.mjs.",
4
4
  "packageManager": "pnpm",
5
5
  "reporters": ["html", "clear-text", "progress"],
6
6
  "coverageAnalysis": "perTest",
7
7
  "ignoreStatic": true,
8
8
  "cleanTempDir": true,
9
- "timeoutMS": 60000,
9
+ "disableBail_comment": "Bail is OFF deliberately. Under Stryker's default bail the vitest runner can score a mutant Survived having completed zero of its covering tests, so a run reports a number nothing measured and a committed baseline becomes a floor under it. Every mutant now runs its full covering set, which lengthens the run — the three timeouts below are sized for that and must move together with this flag. Asserted by scripts/stryker-base-config.test.mjs.",
10
+ "disableBail": true,
11
+ "timeoutMS": 120000,
12
+ "timeoutFactor": 3,
13
+ "dryRunTimeoutMinutes": 15,
14
+ "thresholds_comment": "`break: 50` is unchanged, and deliberately so. It was chosen while bail deflated the measured score (a consumer recorded 53.5% against a real 72.29%), but that deflation runs one way only: a bail-free score is higher, so a break calibrated against a deflated number cannot fail a run it would previously have passed. Raising it fleet-wide would be the very sin the flag above exists to prevent — a fail-closed number nothing measured, imposed on consumers whose real scores are unknown from here. So 50 stays a floor of last resort rather than a calibrated target: after re-deriving a baseline on the bail-free base (docs/patterns.md, 'Adopting the bail-free Stryker base'), set a tighter break locally. `high`/`low` are report-colour bands and fail nothing.",
10
15
  "thresholds": {
11
16
  "high": 80,
12
17
  "low": 60,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.2.0",
3
+ "version": "1.4.0",
4
4
  "description": "Shared CI/deploy workflows, composite toolchain action, npm config package, Renovate preset, and operator runbook templates.",
5
5
  "license": "MIT",
6
6
  "repository": {
@@ -44,7 +44,7 @@
44
44
  "provenance": true
45
45
  },
46
46
  "dependencies": {
47
- "mandrel": "^2.7.0"
47
+ "mandrel": "^2.31.0"
48
48
  },
49
49
  "scripts": {
50
50
  "typecheck": "node --input-type=module --eval 'process.exit(0)'",
@@ -18,16 +18,28 @@
18
18
  * an uninterpretable report means the gate cannot prove the graph is
19
19
  * clean, so it must fail closed rather than wave the build through.
20
20
  *
21
+ * Unbounded-override lint (Story #365):
22
+ * A dependency override REWRITES a transitive dependent's declared range.
23
+ * Written as a bare lower bound (`">=1.2.3"`, `"*"`, `"x.x.x"`) or as a
24
+ * non-registry specifier (`"github:owner/repo"`, `"workspace:*"`) it is
25
+ * open-ended, so
26
+ * the committed lockfile becomes the only pin and any fresh resolution
27
+ * re-picks the newest release — which can cross a major. Nothing else in the
28
+ * toolchain lints for that, so this gate names each such override and its
29
+ * bound before it runs the audit.
30
+ *
21
31
  * Usage:
22
32
  * node scripts/audit-check.mjs
23
33
  * node scripts/audit-check.mjs --allowlist path/to/allowlist.json
34
+ * node scripts/audit-check.mjs --package-json path/to/package.json
24
35
  *
25
36
  * Exit codes:
26
37
  * 0 — no blocking vulnerabilities (all High/Critical suppressed with
27
- * valid, non-expired allowlist entries, or none found)
38
+ * valid, non-expired allowlist entries, or none found) and every
39
+ * dependency override carries an upper bound
28
40
  * 1 — one or more unsuppressed High/Critical CVEs, expired allowlist
29
- * entries were encountered, or the audit report was uninterpretable
30
- * while pnpm audit exited non-zero
41
+ * entries were encountered, an override was unbounded, or the audit
42
+ * report was uninterpretable while pnpm audit exited non-zero
31
43
  *
32
44
  * Allowlist format (JSON):
33
45
  * [
@@ -183,6 +195,251 @@ export function partitionAllowlist(allowlist, today) {
183
195
  return { suppressed, expired, invalid };
184
196
  }
185
197
 
198
+ /**
199
+ * Fields a package.json can express dependency overrides through. All three
200
+ * are checked, because a repo that has migrated package managers routinely
201
+ * carries more than one and an unbounded bound is equally open-ended in any
202
+ * of them.
203
+ */
204
+ const OVERRIDE_FIELDS = ["overrides", "resolutions", "pnpm.overrides"];
205
+
206
+ /**
207
+ * Specifier prefixes that are not registry semver ranges at all: a git ref, a
208
+ * workspace sibling, a local path, a tarball URL. Each re-resolves to whatever
209
+ * that source holds at install time — `github:owner/repo` tracks the default
210
+ * branch, `workspace:*` tracks the sibling's current version — so none of them
211
+ * expresses an upper bound and none can be judged bounded. Before Story #375
212
+ * they all fell through to the catch-all and were reported bounded.
213
+ */
214
+ const NON_SEMVER_PROTOCOLS = [
215
+ "bitbucket:",
216
+ "file:",
217
+ "gist:",
218
+ "git+",
219
+ "git:",
220
+ "github:",
221
+ "gitlab:",
222
+ "http:",
223
+ "https:",
224
+ "link:",
225
+ "portal:",
226
+ "workspace:",
227
+ ];
228
+
229
+ /**
230
+ * Is `value` a wildcard that pins nothing — `*`, a dist-tag, or a dotted
231
+ * wildcard such as `x.x.x`, `x.x` or `*.*.*`?
232
+ *
233
+ * The MAJOR position is the whole test, because npm reads `x.x.x` and `x.x` as
234
+ * exactly `*` (any version). A wildcard below the major — `1.2.x`, `1.*` —
235
+ * stays inside major 1 and is genuinely bounded, so only a wildcard in the
236
+ * first position is open-ended. Story #375: the enumerated regex this replaces
237
+ * listed `*`, `x` and `*.*.*` but not the dotted `x` forms, which is how the
238
+ * exact shape the lint exists to catch read as bounded.
239
+ *
240
+ * Deliberately answers for a SINGLE bare token only. A compound range spells
241
+ * its own upper bound out (`x.x <2.0.0`, `x.x.x - 2.0.0`) and is judged on
242
+ * that by the caller; reading the first dot-segment of the whole string would
243
+ * call such a range unbounded purely on its lower end.
244
+ *
245
+ * @param {string} value trimmed specifier
246
+ * @returns {boolean}
247
+ */
248
+ function isWildcardSpec(value) {
249
+ if (/\s/.test(value)) {
250
+ return false;
251
+ }
252
+
253
+ if (/^(latest|next)$/i.test(value)) {
254
+ return true;
255
+ }
256
+
257
+ return /^[*xX]$/.test(value.split(".")[0]);
258
+ }
259
+
260
+ /**
261
+ * Strip a leading range operator from a single bare token.
262
+ *
263
+ * `isWildcardSpec` reads the major position, which a leading operator shifts
264
+ * out of view: `^x.x.x` splits to `^x`, matches nothing, and reads as a real
265
+ * version. An operator applied to "any version" is still any version, so the
266
+ * operator has to come off before the major position can be judged.
267
+ *
268
+ * @param {string} term single bare token
269
+ * @returns {string}
270
+ */
271
+ function stripRangeOperator(term) {
272
+ return term.replace(/^(?:[<>]=?|[\^~=])+/, "");
273
+ }
274
+
275
+ /**
276
+ * Does this single bare token carry a real upper bound?
277
+ *
278
+ * A wildcard pins nothing, and a bare lower bound (`>=1.0.0`) is open above by
279
+ * construction. Anything else — an exact pin, a caret/tilde range, an `x`-style
280
+ * partial with a fixed major, an explicit `<` cap — closes the range.
281
+ *
282
+ * @param {string} term single bare token
283
+ * @returns {boolean}
284
+ */
285
+ function termCarriesUpperBound(term) {
286
+ if (isWildcardSpec(stripRangeOperator(term))) {
287
+ return false;
288
+ }
289
+
290
+ return !/^>=?/.test(term);
291
+ }
292
+
293
+ /**
294
+ * Is this override specifier bounded above?
295
+ *
296
+ * An override REWRITES a transitive dependent's declared range, so whatever is
297
+ * written here is the only thing standing between the tree and the next
298
+ * release of that package. A bare lower bound (`>=1.2.3`, `>1.2.3`, `*`,
299
+ * `x.x.x`, `latest`) leaves the committed lockfile as the sole pin: the moment
300
+ * anything re-resolves — a fresh install, a lockfile-less CI leg, a
301
+ * dependent's own bump — the newest release wins and can cross a major. That
302
+ * is how a major jump silently emptied a consumer's test suite.
303
+ *
304
+ * Bounded means the specifier can never cross a major on its own: an exact
305
+ * pin, a caret/tilde range, an `x`-style partial whose major is fixed
306
+ * (`1.2.x`), or a compound range carrying an explicit upper bound
307
+ * (`>=1.2.3 <2`).
308
+ *
309
+ * @param {string} spec
310
+ * @returns {boolean}
311
+ */
312
+ export function isBoundedOverride(spec) {
313
+ if (typeof spec !== "string") {
314
+ return false;
315
+ }
316
+
317
+ const value = spec.trim();
318
+ if (value === "") {
319
+ return false;
320
+ }
321
+
322
+ // `npm:other-pkg@<range>` and `pkg@<range>` alias forms are judged on the
323
+ // range they carry, not on the alias target.
324
+ if (value.startsWith("npm:")) {
325
+ const aliasMatch = /^npm:(?:@[^/]+\/)?[^@\s]+@(.+)$/.exec(value);
326
+ return aliasMatch ? isBoundedOverride(aliasMatch[1]) : false;
327
+ }
328
+
329
+ // A non-registry specifier resolves outside semver entirely. Only the
330
+ // explicit `#semver:<range>` fragment a git URL may carry is a real range,
331
+ // and it is judged on its own merits exactly as an `npm:` alias is.
332
+ const lower = value.toLowerCase();
333
+ if (NON_SEMVER_PROTOCOLS.some((protocol) => lower.startsWith(protocol))) {
334
+ const semverFragment = /#semver:(.+)$/.exec(value);
335
+ return semverFragment ? isBoundedOverride(semverFragment[1]) : false;
336
+ }
337
+
338
+ // A `||` union is only as bounded as its loosest arm.
339
+ if (value.includes("||")) {
340
+ return value.split("||").every((arm) => isBoundedOverride(arm));
341
+ }
342
+
343
+ // Wildcards and dist-tags pin nothing at all — including behind a leading
344
+ // range operator, which shifts the major position out of view.
345
+ if (isWildcardSpec(stripRangeOperator(value))) {
346
+ return false;
347
+ }
348
+
349
+ // A hyphen range is bounded by its right-hand side alone: `x.x.x - 2.0.0`
350
+ // caps at 2.0.0 however loose its lower end is, and `x.x - x.x` caps at
351
+ // nothing however much it is spelled like a range.
352
+ //
353
+ // A real hyphen range takes plain versions on both sides. A comparator on
354
+ // either end (`>=1.0.0 - 2.0.0`) is malformed, so reading its right-hand
355
+ // side as the cap would answer a range npm never agreed to parse — that one
356
+ // is left to the compound logic below, which keeps failing closed on it.
357
+ const hyphenRange = /^(.+?)\s+-\s+(.+)$/.exec(value);
358
+ if (hyphenRange) {
359
+ const lowerEnd = hyphenRange[1].trim();
360
+ const upperEnd = hyphenRange[2].trim();
361
+ if (!/^[<>]/.test(lowerEnd) && !/^[<>]/.test(upperEnd)) {
362
+ return termCarriesUpperBound(upperEnd);
363
+ }
364
+ }
365
+
366
+ // An explicit upper bound anywhere in a space-separated compound closes it.
367
+ if (/[<]/.test(value)) {
368
+ return true;
369
+ }
370
+
371
+ // Otherwise a compound is only as bounded as its terms: when every one of
372
+ // them is a wildcard or a bare lower bound, nothing caps the range and the
373
+ // range-like spelling is the only thing suggesting otherwise.
374
+ if (/\s/.test(value) && !value.split(/\s+/).some(termCarriesUpperBound)) {
375
+ return false;
376
+ }
377
+
378
+ // A bare lower bound is the unbounded shape this check exists to name.
379
+ if (/^[>]=?/.test(value)) {
380
+ return false;
381
+ }
382
+
383
+ return true;
384
+ }
385
+
386
+ /**
387
+ * Report every override in `pkgJson` expressed as an unbounded lower bound.
388
+ *
389
+ * Pure and package-manager agnostic: the caller supplies the parsed
390
+ * package.json, so this is unit-testable without a fixture tree.
391
+ *
392
+ * @param {unknown} pkgJson parsed package.json
393
+ * @returns {Array<{ field: string; package: string; bound: string }>}
394
+ */
395
+ export function findUnboundedOverrides(pkgJson) {
396
+ /** @type {Array<{ field: string; package: string; bound: string }>} */
397
+ const findings = [];
398
+
399
+ if (pkgJson === null || typeof pkgJson !== "object") {
400
+ return findings;
401
+ }
402
+
403
+ for (const field of OVERRIDE_FIELDS) {
404
+ /** @type {unknown} */
405
+ let node = pkgJson;
406
+ for (const segment of field.split(".")) {
407
+ node =
408
+ node !== null && typeof node === "object"
409
+ ? /** @type {Record<string, unknown>} */ (node)[segment]
410
+ : undefined;
411
+ }
412
+
413
+ if (node === null || typeof node !== "object" || Array.isArray(node)) {
414
+ continue;
415
+ }
416
+
417
+ for (const [name, spec] of Object.entries(
418
+ /** @type {Record<string, unknown>} */ (node),
419
+ )) {
420
+ // A nested override object scopes a bound to one dependent; recurse so a
421
+ // nested unbounded bound is named too, keyed by its full path.
422
+ if (spec !== null && typeof spec === "object" && !Array.isArray(spec)) {
423
+ for (const nested of findUnboundedOverrides({ overrides: spec })) {
424
+ findings.push({
425
+ field,
426
+ package: `${name}.${nested.package}`,
427
+ bound: nested.bound,
428
+ });
429
+ }
430
+ continue;
431
+ }
432
+
433
+ const bound = typeof spec === "string" ? spec : String(spec);
434
+ if (!isBoundedOverride(bound)) {
435
+ findings.push({ field, package: name, bound });
436
+ }
437
+ }
438
+ }
439
+
440
+ return findings;
441
+ }
442
+
186
443
  /**
187
444
  * True when `report` has the recognizable pnpm-audit shape: an object with
188
445
  * an `advisories` object. This is the discriminator the fail-closed contract
@@ -304,23 +561,30 @@ export function evaluateReport(report, auditExitCode, suppressed) {
304
561
  *
305
562
  * @param {string[]} argv
306
563
  * @param {string} [cwd]
307
- * @returns {{ allowlistPath: string }}
564
+ * @returns {{ allowlistPath: string; packageJsonPath: string }}
308
565
  */
309
566
  export function parseArgs(argv, cwd = process.cwd()) {
310
567
  let allowlistPath = null;
568
+ let packageJsonPath = null;
311
569
 
312
570
  for (let i = 0; i < argv.length; i++) {
313
571
  if (argv[i] === "--allowlist" && argv[i + 1]) {
314
572
  allowlistPath = resolve(cwd, argv[i + 1]);
315
573
  i++;
574
+ } else if (argv[i] === "--package-json" && argv[i + 1]) {
575
+ packageJsonPath = resolve(cwd, argv[i + 1]);
576
+ i++;
316
577
  }
317
578
  }
318
579
 
319
580
  if (allowlistPath === null) {
320
581
  allowlistPath = resolve(cwd, "audit-allowlist.json");
321
582
  }
583
+ if (packageJsonPath === null) {
584
+ packageJsonPath = resolve(cwd, "package.json");
585
+ }
322
586
 
323
- return { allowlistPath };
587
+ return { allowlistPath, packageJsonPath };
324
588
  }
325
589
 
326
590
  /**
@@ -346,6 +610,57 @@ export function loadAllowlist(allowlistPath) {
346
610
  return parsed;
347
611
  }
348
612
 
613
+ /**
614
+ * Gate a package.json on unbounded dependency overrides. Returns the process
615
+ * exit code (0 clean, 1 blocking) and prints what is wrong and how to fix it.
616
+ *
617
+ * Split out of `runCli` so BOTH outcomes are executable in a test: the clean
618
+ * path returns here without ever reaching `pnpm audit`, which needs a real
619
+ * lockfile and a network. A missing package.json is not this gate's business —
620
+ * the audit is what proves the graph.
621
+ *
622
+ * @param {string} packageJsonPath
623
+ * @returns {number}
624
+ */
625
+ export function lintOverrides(packageJsonPath) {
626
+ if (!existsSync(packageJsonPath)) {
627
+ return 0;
628
+ }
629
+
630
+ /** @type {unknown} */
631
+ let pkgJson;
632
+ try {
633
+ pkgJson = JSON.parse(readFileSync(packageJsonPath, "utf8"));
634
+ } catch (err) {
635
+ console.error(
636
+ `[audit-check] ERROR: could not parse ${packageJsonPath}: ${
637
+ err instanceof Error ? err.message : String(err)
638
+ }`,
639
+ );
640
+ return 1;
641
+ }
642
+
643
+ const unbounded = findUnboundedOverrides(pkgJson);
644
+ if (unbounded.length === 0) {
645
+ return 0;
646
+ }
647
+
648
+ console.error(
649
+ `[audit-check] ${unbounded.length} unbounded dependency override(s) in ${packageJsonPath}:`,
650
+ );
651
+ for (const finding of unbounded) {
652
+ console.error(
653
+ ` - ${finding.field}.${finding.package}: "${finding.bound}" has no upper bound`,
654
+ );
655
+ }
656
+ console.error(
657
+ "\n[audit-check] An override rewrites a dependent's range, so a bare lower bound leaves " +
658
+ "the lockfile as the only pin and lets a fresh resolution cross a major. Give each " +
659
+ 'bound an upper limit — "^1.2.3", "~1.2.3", or ">=1.2.3 <2.0.0". Exit 1.',
660
+ );
661
+ return 1;
662
+ }
663
+
349
664
  /**
350
665
  * Run `pnpm audit --prod --json`, returning the raw stdout and exit code.
351
666
  * pnpm audit exits non-zero when vulnerabilities are found; we want the JSON
@@ -372,7 +687,17 @@ function runPnpmAudit() {
372
687
  * @returns {number}
373
688
  */
374
689
  export function runCli(argv) {
375
- const { allowlistPath } = parseArgs(argv);
690
+ const { allowlistPath, packageJsonPath } = parseArgs(argv);
691
+
692
+ // --- Lint dependency overrides -------------------------------------------
693
+ //
694
+ // Runs BEFORE the audit: an unbounded override is a standing invitation for
695
+ // the next resolution to cross a major, and nothing else in the toolchain
696
+ // looks for one.
697
+ const overrideExit = lintOverrides(packageJsonPath);
698
+ if (overrideExit !== 0) {
699
+ return overrideExit;
700
+ }
376
701
 
377
702
  // --- Load & validate the allowlist ---------------------------------------
378
703