mandrel-platform 1.11.0 → 1.12.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
@@ -496,13 +496,22 @@ Dependency gate script with **two independent blocking conditions**. It exits
496
496
  non-zero when *either* fires — a clean CVE scan does not excuse an unbounded
497
497
  override, and vice versa:
498
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.
499
+ 1. **CVE gate.** Detects the package manager from the committed lockfile —
500
+ `pnpm-lock.yaml` means pnpm, `package-lock.json` means npm — runs that
501
+ manager's audit over the production graph, and blocks on any
502
+ **unsuppressed** High or Critical vulnerability in it. This is the stricter
503
+ athportal/swarm-os policy: all unsuppressed High/Critical are blocking, not
504
+ just fixable ones. The two managers report in different schemas (a legacy
505
+ `advisories` map; npm v7+ nests advisories under `vulnerabilities`), and
506
+ both are read — a report matching **neither** fails the gate closed rather
507
+ than reading as clean.
503
508
  2. **Unbounded-override lint.** Blocks on any dependency override written
504
509
  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.
510
+ CVEs present**. It runs *first*, before any audit is invoked at all.
511
+
512
+ > Neither lockfile present, or both, is a configuration error the gate reports
513
+ > and exits non-zero on. It will not guess which dependency graph its verdict
514
+ > is about.
506
515
 
507
516
  Known/accepted CVEs are suppressed via a **dated, self-expiring allowlist**
508
517
  (`audit-allowlist.json` in the project root). Expired entries are treated as
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "mandrel-platform",
3
- "version": "1.11.0",
3
+ "version": "1.12.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": {
@@ -60,5 +60,8 @@
60
60
  "devDependencies": {
61
61
  "@biomejs/biome": "2.5.0",
62
62
  "markdownlint-cli2": "0.23.2"
63
+ },
64
+ "overrides": {
65
+ "smol-toml": "~1.7.1"
63
66
  }
64
67
  }
@@ -11,12 +11,26 @@
11
11
  * whose expiry has passed are treated as un-suppressed and will cause
12
12
  * the script to exit non-zero.
13
13
  *
14
+ * Package managers (Story #475):
15
+ * The audit runs under whichever manager the COMMITTED LOCKFILE names —
16
+ * `pnpm-lock.yaml` → pnpm, `package-lock.json` → npm — not what
17
+ * `packageManager` or `engines` declares, because the lockfile is what the
18
+ * audit reads and metadata can disagree with it. Neither lockfile, or both,
19
+ * is a loud configuration error: this gate's output is a claim about a
20
+ * specific dependency graph, and guessing which graph would make that claim
21
+ * unfalsifiable.
22
+ *
14
23
  * Fail-closed contract:
15
- * When `pnpm audit` exits non-zero AND the report it produced cannot be
16
- * interpreted as a recognizable advisories document, the gate exits
17
- * non-zero. A non-zero audit exit is a signal that something is wrong;
18
- * an uninterpretable report means the gate cannot prove the graph is
19
- * clean, so it must fail closed rather than wave the build through.
24
+ * A report counts as clean only when its schema was POSITIVELY RECOGNIZED
25
+ * and found nothing blocking. A report that parsed but matches neither known
26
+ * schema fails the gate on ANY audit exit code, including zero.
27
+ *
28
+ * That last clause is load-bearing. The two managers report differently — a
29
+ * legacy `advisories` map (pnpm / npm v6) versus npm v7+, which nests
30
+ * advisories under `vulnerabilities` — and the earlier contract passed an
31
+ * unrecognized report whenever the audit exited zero. Since `npm audit`
32
+ * exits zero when clean, that branch would have reported an npm graph clean
33
+ * without reading a single advisory, and kept doing so as highs landed.
20
34
  *
21
35
  * Unbounded-override lint (Story #365):
22
36
  * A dependency override REWRITES a transitive dependent's declared range.
@@ -38,8 +52,9 @@
38
52
  * valid, non-expired allowlist entries, or none found) and every
39
53
  * dependency override carries an upper bound
40
54
  * 1 — one or more unsuppressed High/Critical CVEs, expired allowlist
41
- * entries were encountered, an override was unbounded, or the audit
42
- * report was uninterpretable while pnpm audit exited non-zero
55
+ * entries were encountered, an override was unbounded, the package
56
+ * manager could not be determined from a lockfile, or the audit report
57
+ * matched no known schema
43
58
  *
44
59
  * Allowlist format (JSON):
45
60
  * [
@@ -63,7 +78,7 @@
63
78
 
64
79
  import { execSync } from "node:child_process";
65
80
  import { existsSync, readFileSync } from "node:fs";
66
- import { resolve } from "node:path";
81
+ import { dirname, resolve } from "node:path";
67
82
 
68
83
  // ---------------------------------------------------------------------------
69
84
  // Pure core (unit-testable — no process.exit, no filesystem, no child process)
@@ -441,115 +456,311 @@ export function findUnboundedOverrides(pkgJson) {
441
456
  }
442
457
 
443
458
  /**
444
- * True when `report` has the recognizable pnpm-audit shape: an object with
445
- * an `advisories` object. This is the discriminator the fail-closed contract
446
- * hangs on — a parsed-but-unrecognizable report (e.g. an error envelope) is
447
- * NOT interpretable.
459
+ * Lockfiles this gate knows how to audit, in the order they are probed.
448
460
  *
449
- * @param {unknown} report
450
- * @returns {boolean}
461
+ * The lockfile — not `packageManager`, not `engines` — is the discriminator,
462
+ * because it is the thing the audit actually reads. A repo can declare one
463
+ * manager in metadata and commit the other's lockfile (this one does), and it
464
+ * is the lockfile that decides whether an audit can run at all.
451
465
  */
452
- export function isInterpretableReport(report) {
453
- return (
454
- report !== null &&
455
- typeof report === "object" &&
456
- "advisories" in report &&
457
- /** @type {Record<string, unknown>} */ (report).advisories !== null &&
458
- typeof (/** @type {Record<string, unknown>} */ (report).advisories) ===
459
- "object"
466
+ const LOCKFILES = [
467
+ { file: "pnpm-lock.yaml", manager: "pnpm" },
468
+ { file: "package-lock.json", manager: "npm" },
469
+ ];
470
+
471
+ /**
472
+ * Resolve which package manager's audit to run for the project rooted at
473
+ * `projectDir`.
474
+ *
475
+ * Returns `{ manager }` on a clean read, or `{ error }` naming what is wrong.
476
+ * Both ambiguity (two lockfiles) and absence (none) are errors rather than a
477
+ * best guess: this gate's whole output is a claim about a specific dependency
478
+ * graph, and guessing which graph would make that claim unfalsifiable.
479
+ *
480
+ * `existsSyncImpl` is injectable so the decision is unit-testable without
481
+ * materializing a fixture tree per case.
482
+ *
483
+ * @param {string} projectDir directory holding the audited package.json
484
+ * @param {{ existsSyncImpl?: (p: string) => boolean }} [deps]
485
+ * @returns {{ manager: "pnpm" | "npm"; error?: undefined } | { manager?: undefined; error: string }}
486
+ */
487
+ export function detectPackageManager(projectDir, { existsSyncImpl = existsSync } = {}) {
488
+ const found = LOCKFILES.filter(({ file }) =>
489
+ existsSyncImpl(resolve(projectDir, file)),
460
490
  );
491
+
492
+ if (found.length === 1) {
493
+ return { manager: /** @type {"pnpm" | "npm"} */ (found[0].manager) };
494
+ }
495
+
496
+ if (found.length === 0) {
497
+ return {
498
+ error:
499
+ `No lockfile found in ${projectDir}. Expected one of: ` +
500
+ `${LOCKFILES.map(({ file }) => file).join(", ")}. The audit reads the ` +
501
+ `lockfile, so without one there is no dependency graph to prove.`,
502
+ };
503
+ }
504
+
505
+ return {
506
+ error:
507
+ `Ambiguous lockfiles in ${projectDir}: ${found.map(({ file }) => file).join(" and ")}. ` +
508
+ `Remove the one that is not authoritative — this gate will not guess ` +
509
+ `which dependency graph its verdict is about.`,
510
+ };
461
511
  }
462
512
 
463
513
  /**
464
- * Extract the blocking (unsuppressed High/Critical) advisories from an
465
- * interpretable pnpm-audit report. An advisory is suppressed when any of its
466
- * ids (GHSA id or CVE ids) is present in `suppressed`.
514
+ * The GHSA id embedded in an advisory URL, or `null`.
467
515
  *
468
- * Callers MUST gate this behind `isInterpretableReport` — an
469
- * uninterpretable report yields an empty array here, which is exactly the
470
- * fail-open trap the CLI guards against separately.
516
+ * npm's report never exposes a bare `ghsa_id`; the only place the id appears
517
+ * is the advisory `url` (`https://github.com/advisories/GHSA-xxxx-xxxx-xxxx`),
518
+ * and the allowlist matches on that id. The URL is parsed and its LAST PATH
519
+ * SEGMENT tested against an anchored literal — never pattern-matched as a
520
+ * whole string, which would match a GHSA-shaped substring anywhere in a
521
+ * caller-controlled URL, host included.
471
522
  *
472
- * @param {unknown} report
473
- * @param {Set<string>} suppressed active (non-expired) suppressed ids
474
- * @returns {Array<{ id: string; severity: string; title: string; url: string }>}
523
+ * @param {unknown} url
524
+ * @returns {string|null}
475
525
  */
476
- export function extractBlockingAdvisories(report, suppressed) {
477
- /** @type {Array<{ id: string; severity: string; title: string; url: string }>} */
478
- const blocking = [];
526
+ export function ghsaIdFromUrl(url) {
527
+ if (typeof url !== "string" || url === "") {
528
+ return null;
529
+ }
479
530
 
480
- if (!isInterpretableReport(report)) {
481
- return blocking;
531
+ let segment;
532
+ try {
533
+ const segments = new URL(url).pathname.split("/").filter(Boolean);
534
+ segment = segments[segments.length - 1];
535
+ } catch {
536
+ return null;
482
537
  }
483
538
 
484
- const advisories = /** @type {Record<string, unknown>} */ (
485
- /** @type {Record<string, unknown>} */ (report).advisories
486
- );
539
+ if (typeof segment !== "string") {
540
+ return null;
541
+ }
542
+
543
+ return /^GHSA-[0-9a-z]{4}-[0-9a-z]{4}-[0-9a-z]{4}$/i.test(segment)
544
+ ? segment.toUpperCase()
545
+ : null;
546
+ }
547
+
548
+ /**
549
+ * @typedef {{ ids: string[]; severity: string; title: string; url: string }} NormalizedAdvisory
550
+ */
487
551
 
488
- for (const [, advisory] of Object.entries(advisories)) {
489
- if (
490
- advisory === null ||
491
- typeof advisory !== "object" ||
492
- !("severity" in advisory)
493
- ) {
552
+ /**
553
+ * Normalize the legacy (npm v6 / pnpm) `advisories` map.
554
+ *
555
+ * @param {Record<string, unknown>} report
556
+ * @returns {NormalizedAdvisory[]}
557
+ */
558
+ function normalizeLegacyReport(report) {
559
+ /** @type {NormalizedAdvisory[]} */
560
+ const out = [];
561
+ const advisories = /** @type {Record<string, unknown>} */ (report.advisories);
562
+
563
+ for (const advisory of Object.values(advisories)) {
564
+ if (advisory === null || typeof advisory !== "object" || !("severity" in advisory)) {
494
565
  continue;
495
566
  }
496
-
497
567
  const adv = /** @type {Record<string, unknown>} */ (advisory);
498
- const severity = String(adv["severity"] ?? "").toLowerCase();
568
+ const ghsaId = String(adv["ghsa_id"] ?? "");
569
+ const cveIds = Array.isArray(adv["cve"]) ? adv["cve"].map((c) => String(c)) : [];
570
+
571
+ out.push({
572
+ ids: [ghsaId, ...cveIds].filter(Boolean),
573
+ severity: String(adv["severity"] ?? "").toLowerCase(),
574
+ title: String(adv["title"] ?? "(no title)"),
575
+ url: String(adv["url"] ?? ""),
576
+ });
577
+ }
499
578
 
500
- if (!BLOCKING_SEVERITIES.has(severity)) {
579
+ return out;
580
+ }
581
+
582
+ /**
583
+ * Normalize an npm v7+ (`auditReportVersion` 2) report.
584
+ *
585
+ * Advisories are not a top-level map here: they are nested in
586
+ * `vulnerabilities[<pkg>].via[]`, where an entry is either a STRING (the name
587
+ * of another vulnerable package, for a transitive chain) or an advisory
588
+ * object. Only the objects carry an advisory; the strings are edges and are
589
+ * skipped, so a transitive chain is counted once at its source rather than
590
+ * once per hop.
591
+ *
592
+ * @param {Record<string, unknown>} report
593
+ * @returns {NormalizedAdvisory[]}
594
+ */
595
+ function normalizeNpmReport(report) {
596
+ /** @type {Map<string, NormalizedAdvisory>} */
597
+ const bySource = new Map();
598
+ const vulnerabilities = /** @type {Record<string, unknown>} */ (report.vulnerabilities);
599
+
600
+ for (const entry of Object.values(vulnerabilities)) {
601
+ if (entry === null || typeof entry !== "object") {
602
+ continue;
603
+ }
604
+ const via = /** @type {Record<string, unknown>} */ (entry)["via"];
605
+ if (!Array.isArray(via)) {
501
606
  continue;
502
607
  }
503
608
 
504
- // Collect all IDs this advisory is known by for allowlist matching.
505
- const ghsaId = String(adv["ghsa_id"] ?? "");
506
- const cveIds = Array.isArray(adv["cve"])
507
- ? adv["cve"].map((c) => String(c))
508
- : [];
509
- const allIds = [ghsaId, ...cveIds].filter(Boolean);
510
-
511
- const isSuppressed = allIds.some((id) => suppressed.has(id));
609
+ for (const item of via) {
610
+ if (item === null || typeof item !== "object") {
611
+ continue; // a string edge in a transitive chain, not an advisory
612
+ }
613
+ const adv = /** @type {Record<string, unknown>} */ (item);
614
+ const url = String(adv["url"] ?? "");
615
+ const ghsaId = ghsaIdFromUrl(url);
616
+ const source = adv["source"] === undefined ? "" : String(adv["source"]);
617
+ const cveIds = Array.isArray(adv["cve"]) ? adv["cve"].map((c) => String(c)) : [];
618
+ const ids = [ghsaId ?? "", ...cveIds].filter(Boolean);
619
+
620
+ // Key on the advisory's own identity so one advisory reachable through
621
+ // several packages is reported once.
622
+ const key = ghsaId ?? source ?? url;
623
+ if (key === "" || bySource.has(key)) {
624
+ continue;
625
+ }
512
626
 
513
- if (!isSuppressed) {
514
- blocking.push({
515
- id: ghsaId || cveIds[0] || "(unknown)",
516
- severity,
627
+ bySource.set(key, {
628
+ ids,
629
+ severity: String(adv["severity"] ?? "").toLowerCase(),
517
630
  title: String(adv["title"] ?? "(no title)"),
518
- url: String(adv["url"] ?? ""),
631
+ url,
519
632
  });
520
633
  }
521
634
  }
522
635
 
636
+ return [...bySource.values()];
637
+ }
638
+
639
+ /**
640
+ * Positively identify a parsed audit report and normalize its advisories.
641
+ *
642
+ * **Recognition is positive, and that is the load-bearing property.** A report
643
+ * counts as clean only when a schema was RECOGNIZED and found nothing
644
+ * blocking; a report that parsed but matches nothing returns
645
+ * `{ schema: null }` and the caller fails closed on it regardless of the audit
646
+ * process's exit code. The alternative — treating "no advisories key" as "no
647
+ * advisories" — is how an npm report (which keeps them under
648
+ * `vulnerabilities`) would read as clean without ever being inspected.
649
+ *
650
+ * @param {unknown} report
651
+ * @returns {{ schema: "legacy" | "npm" | null; advisories: NormalizedAdvisory[] }}
652
+ */
653
+ export function recognizeReport(report) {
654
+ if (report === null || typeof report !== "object") {
655
+ return { schema: null, advisories: [] };
656
+ }
657
+
658
+ const obj = /** @type {Record<string, unknown>} */ (report);
659
+
660
+ if (obj.advisories !== null && typeof obj.advisories === "object") {
661
+ return { schema: "legacy", advisories: normalizeLegacyReport(obj) };
662
+ }
663
+
664
+ if (obj.vulnerabilities !== null && typeof obj.vulnerabilities === "object") {
665
+ return { schema: "npm", advisories: normalizeNpmReport(obj) };
666
+ }
667
+
668
+ return { schema: null, advisories: [] };
669
+ }
670
+
671
+ /**
672
+ * True when `report` has the recognizable legacy (npm v6 / pnpm) shape: an
673
+ * object with an `advisories` object.
674
+ *
675
+ * Retained as the narrow legacy-shape predicate it always was. It is NOT the
676
+ * fail-closed discriminator any more — `recognizeReport` is, because a report
677
+ * this returns `false` for may still be a perfectly readable npm report.
678
+ *
679
+ * @param {unknown} report
680
+ * @returns {boolean}
681
+ */
682
+ export function isInterpretableReport(report) {
683
+ return recognizeReport(report).schema === "legacy";
684
+ }
685
+
686
+ /**
687
+ * Extract the blocking (unsuppressed High/Critical) advisories from a report
688
+ * in EITHER schema. An advisory is suppressed when any of its ids (GHSA or
689
+ * CVE) is present in `suppressed`.
690
+ *
691
+ * An unrecognized report yields an empty array here; callers MUST gate on
692
+ * `recognizeReport(...).schema` rather than on emptiness, which is exactly the
693
+ * fail-open trap `evaluateReport` closes.
694
+ *
695
+ * @param {unknown} report
696
+ * @param {Set<string>} suppressed active (non-expired) suppressed ids
697
+ * @returns {Array<{ id: string; severity: string; title: string; url: string }>}
698
+ */
699
+ export function extractBlockingAdvisories(report, suppressed) {
700
+ /** @type {Array<{ id: string; severity: string; title: string; url: string }>} */
701
+ const blocking = [];
702
+
703
+ for (const advisory of recognizeReport(report).advisories) {
704
+ if (!BLOCKING_SEVERITIES.has(advisory.severity)) {
705
+ continue;
706
+ }
707
+ if (advisory.ids.some((id) => suppressed.has(id))) {
708
+ continue;
709
+ }
710
+ blocking.push({
711
+ id: advisory.ids[0] ?? "(unknown)",
712
+ severity: advisory.severity,
713
+ title: advisory.title,
714
+ url: advisory.url,
715
+ });
716
+ }
717
+
523
718
  return blocking;
524
719
  }
525
720
 
526
721
  /**
527
- * Pure evaluation of a parsed audit report against the active suppression
528
- * set and the pnpm-audit exit code. This is the fail-closed decision core,
529
- * lifted out of the CLI so it is unit-testable without spawning pnpm.
722
+ * Pure evaluation of a parsed audit report against the active suppression set
723
+ * and the audit process's exit code. This is the fail-closed decision core,
724
+ * lifted out of the CLI so it is unit-testable without spawning a package
725
+ * manager.
726
+ *
727
+ * An unrecognized report fails closed on ANY exit code, including zero. It
728
+ * used to pass on a zero exit — the branch that would have let an npm report
729
+ * (advisories under `vulnerabilities`, exit 0 when clean) report clean without
730
+ * being read at all.
731
+ *
732
+ * `_auditExitCode` is retained but no longer consulted, and deliberately so on
733
+ * both counts. It is retained because this function is exported from a
734
+ * published package, so dropping the parameter would break an importer's call.
735
+ * It is not consulted because the decision no longer has anything to ask it:
736
+ * an unrecognized report fails closed whatever the exit code, and a recognized
737
+ * one is judged on the advisories it actually contains. Do not reintroduce
738
+ * "exit code 0 means clean" — that is precisely the branch this Story removed,
739
+ * and `npm audit` exits 0 whenever it finds nothing, including when the gate
740
+ * never understood the report it was handed.
530
741
  *
531
742
  * @param {unknown} report parsed audit JSON (or `null`)
532
- * @param {number} auditExitCode pnpm audit exit code
743
+ * @param {number} _auditExitCode audit process exit code (unused — see above)
533
744
  * @param {Set<string>} suppressed active (non-expired) suppressed ids
534
- * @returns {{ exitCode: number; reason: "clean" | "uninterpretable-failclosed" | "unsuppressed" | "clean-no-advisories"; blocking: Array<{ id: string; severity: string; title: string; url: string }> }}
745
+ * @returns {{ exitCode: number; reason: "clean" | "uninterpretable-failclosed" | "unsuppressed"; schema: "legacy" | "npm" | null; blocking: Array<{ id: string; severity: string; title: string; url: string }> }}
535
746
  */
536
- export function evaluateReport(report, auditExitCode, suppressed) {
537
- if (!isInterpretableReport(report)) {
538
- if (auditExitCode !== 0) {
539
- return {
540
- exitCode: 1,
541
- reason: "uninterpretable-failclosed",
542
- blocking: [],
543
- };
544
- }
545
- return { exitCode: 0, reason: "clean-no-advisories", blocking: [] };
747
+ export function evaluateReport(report, _auditExitCode, suppressed) {
748
+ const { schema } = recognizeReport(report);
749
+
750
+ if (schema === null) {
751
+ return {
752
+ exitCode: 1,
753
+ reason: "uninterpretable-failclosed",
754
+ schema,
755
+ blocking: [],
756
+ };
546
757
  }
547
758
 
548
759
  const blocking = extractBlockingAdvisories(report, suppressed);
549
760
  if (blocking.length === 0) {
550
- return { exitCode: 0, reason: "clean", blocking };
761
+ return { exitCode: 0, reason: "clean", schema, blocking };
551
762
  }
552
- return { exitCode: 1, reason: "unsuppressed", blocking };
763
+ return { exitCode: 1, reason: "unsuppressed", schema, blocking };
553
764
  }
554
765
 
555
766
  // ---------------------------------------------------------------------------
@@ -615,7 +826,7 @@ export function loadAllowlist(allowlistPath) {
615
826
  * exit code (0 clean, 1 blocking) and prints what is wrong and how to fix it.
616
827
  *
617
828
  * 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
829
+ * path returns here without ever reaching the audit, which needs a real
619
830
  * lockfile and a network. A missing package.json is not this gate's business —
620
831
  * the audit is what proves the graph.
621
832
  *
@@ -662,21 +873,35 @@ export function lintOverrides(packageJsonPath) {
662
873
  }
663
874
 
664
875
  /**
665
- * Run `pnpm audit --prod --json`, returning the raw stdout and exit code.
666
- * pnpm audit exits non-zero when vulnerabilities are found; we want the JSON
876
+ * Audit invocation per manager. Both are restricted to the PRODUCTION graph:
877
+ * this gate's claim is about what ships, and a dev-only advisory would make it
878
+ * unactionable noise. `--omit=dev` is npm's documented spelling of that.
879
+ */
880
+ const AUDIT_COMMANDS = {
881
+ pnpm: "pnpm audit --prod --json",
882
+ npm: "npm audit --omit=dev --json",
883
+ };
884
+
885
+ /**
886
+ * Run the detected manager's audit, returning raw stdout and exit code. Both
887
+ * managers exit non-zero when vulnerabilities are found; the JSON is wanted
667
888
  * regardless of the exit code.
668
889
  *
669
- * @returns {{ output: string; exitCode: number }}
890
+ * @param {"pnpm" | "npm"} manager
891
+ * @returns {{ command: string; output: string; exitCode: number }}
670
892
  */
671
- function runPnpmAudit() {
893
+ function runAudit(manager) {
894
+ const command = AUDIT_COMMANDS[manager];
672
895
  try {
673
- const output = execSync("pnpm audit --prod --json 2>/dev/null", {
674
- encoding: "utf8",
675
- });
676
- return { output, exitCode: 0 };
896
+ const output = execSync(`${command} 2>/dev/null`, { encoding: "utf8" });
897
+ return { command, output, exitCode: 0 };
677
898
  } catch (err) {
678
899
  const execError = /** @type {{ stdout?: string; status?: number }} */ (err);
679
- return { output: execError.stdout ?? "", exitCode: execError.status ?? 1 };
900
+ return {
901
+ command,
902
+ output: execError.stdout ?? "",
903
+ exitCode: execError.status ?? 1,
904
+ };
680
905
  }
681
906
  }
682
907
 
@@ -745,10 +970,27 @@ export function runCli(argv) {
745
970
  return 1;
746
971
  }
747
972
 
748
- // --- Run pnpm audit (production graph only) ------------------------------
973
+ // --- Run the detected manager's audit (production graph only) ------------
974
+
975
+ // --- Detect the package manager ------------------------------------------
976
+ //
977
+ // From the committed lockfile, not from `packageManager` / `engines`: the
978
+ // lockfile is what the audit reads, and metadata can disagree with it.
979
+ const detected = detectPackageManager(dirname(packageJsonPath));
980
+ if (detected.error) {
981
+ console.error(`[audit-check] ERROR: ${detected.error}`);
982
+ return 1;
983
+ }
984
+ const manager = detected.manager;
749
985
 
750
- console.log("[audit-check] Running pnpm audit --prod --json ...");
751
- const { output: auditOutput, exitCode: auditExitCode } = runPnpmAudit();
986
+ console.log(
987
+ `[audit-check] Detected ${manager} from its lockfile; running ${AUDIT_COMMANDS[manager]} ...`,
988
+ );
989
+ const {
990
+ command: auditCommand,
991
+ output: auditOutput,
992
+ exitCode: auditExitCode,
993
+ } = runAudit(manager);
752
994
 
753
995
  // --- Parse audit JSON ----------------------------------------------------
754
996
 
@@ -763,7 +1005,7 @@ export function runCli(argv) {
763
1005
  return 0;
764
1006
  }
765
1007
  console.error(
766
- "[audit-check] ERROR: pnpm audit produced non-JSON output (exit code " +
1008
+ `[audit-check] ERROR: ${auditCommand} produced non-JSON output (exit code ` +
767
1009
  auditExitCode +
768
1010
  ").",
769
1011
  );
@@ -773,11 +1015,11 @@ export function runCli(argv) {
773
1015
 
774
1016
  // --- Evaluate: fail closed on an uninterpretable report + non-zero exit --
775
1017
  //
776
- // The report parsed as JSON. If it lacks a recognizable `advisories` shape
777
- // (e.g. an error envelope) AND pnpm audit exited non-zero, we cannot prove
778
- // the graph is clean — fail closed. A zero exit with no advisories key is
779
- // the genuine "clean, nothing to report" case and passes.
780
- const { exitCode, reason, blocking } = evaluateReport(
1018
+ // The report parsed as JSON. If it matches NEITHER known schema — a legacy
1019
+ // `advisories` map or an npm `vulnerabilities` map — the gate cannot prove
1020
+ // the graph is clean, so it fails closed no matter what the audit exited
1021
+ // with. An empty-but-recognized report is the genuine clean case and passes.
1022
+ const { exitCode, reason, schema, blocking } = evaluateReport(
781
1023
  report,
782
1024
  auditExitCode,
783
1025
  suppressed,
@@ -785,22 +1027,19 @@ export function runCli(argv) {
785
1027
 
786
1028
  if (reason === "uninterpretable-failclosed") {
787
1029
  console.error(
788
- "[audit-check] ERROR: pnpm audit exited non-zero (" +
789
- auditExitCode +
790
- ") and produced a report without a recognizable `advisories` shape. Failing closed.",
1030
+ `[audit-check] ERROR: ${auditCommand} (exit ${auditExitCode}) produced a ` +
1031
+ "report matching no known audit schema — neither a legacy `advisories` " +
1032
+ "map nor an npm `vulnerabilities` map. Failing closed: a report that " +
1033
+ "cannot be read cannot show the graph is clean.",
791
1034
  );
792
1035
  console.error(auditOutput.slice(0, 2000));
793
1036
  return exitCode;
794
1037
  }
795
1038
 
796
- if (reason === "clean-no-advisories") {
797
- console.log("[audit-check] No vulnerabilities found. Exit 0.");
798
- return exitCode;
799
- }
800
-
801
1039
  if (blocking.length === 0) {
802
1040
  console.log(
803
- `[audit-check] No unsuppressed High/Critical vulnerabilities in the prod graph. Exit 0.`,
1041
+ `[audit-check] No unsuppressed High/Critical vulnerabilities in the prod graph ` +
1042
+ `(${manager}, ${schema} schema). Exit 0.`,
804
1043
  );
805
1044
  return exitCode;
806
1045
  }